diff --git a/legacy/types.ts b/legacy/types.ts
new file mode 100644
index 0000000..ca5f43c
--- /dev/null
+++ b/legacy/types.ts
@@ -0,0 +1,62 @@
+
+export type ViewState = 'Dashboard' | 'Live!' | 'Ausschreibungen' | 'Kunden' | 'Experten' | 'Laufende Projekte' | 'Organigramm' | 'Einstellungen';
+
+export interface Expert {
+ id: string;
+ name: string;
+ role: string;
+ email: string;
+ phone: string;
+ linkedin: string;
+ status: 'Interessiert' | 'Interview' | 'Verfügbar' | 'Gebucht';
+ avatar: string;
+ company?: string;
+ bio?: string;
+ tags?: string[];
+}
+
+export interface Contact {
+ id: string;
+ customer_id: string;
+ name: string;
+ role: string;
+ email: string;
+ phone: string;
+ avatar?: string;
+}
+
+export interface Customer {
+ id: string;
+ company: string;
+ name?: string;
+ logo?: string;
+ notes?: string;
+ contacts?: Contact[];
+}
+
+export interface Tender {
+ id: string;
+ source: string;
+ title: string;
+ applicantsCount: number;
+ status: 'Aktiv' | 'Ausgelaufen' | 'Archiviert' | 'Inaktiv';
+ kanbanStatus: KanbanColumn;
+ details?: string;
+ notes?: string;
+ link?: string;
+ selectedCustomer?: Customer;
+ selectedExperts?: Expert[];
+}
+
+export type KanbanColumn = 'Ausschreibung' | 'Experten sichten' | 'Interview' | 'Kunden Interview' | 'Vertragsgestaltung' | 'Abschluss' | 'Geschlossen';
+
+export interface Project {
+ id: string;
+ title: string;
+ customer: string;
+ expert: string;
+ start: string;
+ end: string;
+ status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
+ notes?: string;
+}
diff --git a/metadata.json b/metadata.json
new file mode 100644
index 0000000..a2112c9
--- /dev/null
+++ b/metadata.json
@@ -0,0 +1,5 @@
+{
+ "name": "Icon Expert CRM",
+ "description": "A high-end CRM system for expert placement agencies, featuring Kanban pipelines, tender management, and AI-assisted matching.",
+ "requestFramePermissions": []
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..bc118c5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3426 @@
+{
+ "name": "icon-crm",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "icon-crm",
+ "version": "1.0.0",
+ "dependencies": {
+ "@huggingface/transformers": "^3.8.1",
+ "@tailwindcss/vite": "^4.2.0",
+ "d3-force": "^3.0.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "tailwindcss": "^4.2.0"
+ },
+ "devDependencies": {
+ "@types/d3-force": "^3.0.10",
+ "@types/node": "^22.14.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "~5.8.2",
+ "vite": "^6.2.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@huggingface/jinja": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz",
+ "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@huggingface/transformers": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz",
+ "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@huggingface/jinja": "^0.5.3",
+ "onnxruntime-node": "1.21.0",
+ "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4",
+ "sharp": "^0.34.1"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
+ "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz",
+ "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz",
+ "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz",
+ "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz",
+ "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz",
+ "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz",
+ "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz",
+ "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz",
+ "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz",
+ "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz",
+ "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz",
+ "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz",
+ "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz",
+ "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz",
+ "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz",
+ "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz",
+ "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz",
+ "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz",
+ "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz",
+ "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz",
+ "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz",
+ "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz",
+ "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz",
+ "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz",
+ "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
+ "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.23",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
+ "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT"
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.9",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
+ "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.20",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.420",
+ "node-releases": "^2.0.54",
+ "update-browserslist-db": "^1.3.2"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.427",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz",
+ "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.25.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz",
+ "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/flatbuffers": {
+ "version": "25.9.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/global-agent/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.55",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
+ "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/onnxruntime-common": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
+ "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
+ "license": "MIT"
+ },
+ "node_modules/onnxruntime-node": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
+ "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "os": [
+ "win32",
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "global-agent": "^3.0.0",
+ "onnxruntime-common": "1.21.0",
+ "tar": "^7.0.1"
+ }
+ },
+ "node_modules/onnxruntime-web": {
+ "version": "1.22.0-dev.20250409-89f8206ba4",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz",
+ "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==",
+ "license": "MIT",
+ "dependencies": {
+ "flatbuffers": "^25.1.24",
+ "guid-typescript": "^1.0.9",
+ "long": "^5.2.3",
+ "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4",
+ "platform": "^1.3.6",
+ "protobufjs": "^7.2.4"
+ }
+ },
+ "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
+ "version": "1.22.0-dev.20250409-89f8206ba4",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz",
+ "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/protobufjs": {
+ "version": "7.6.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
+ "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz",
+ "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.2",
+ "@rollup/rollup-android-arm64": "4.63.2",
+ "@rollup/rollup-darwin-arm64": "4.63.2",
+ "@rollup/rollup-darwin-x64": "4.63.2",
+ "@rollup/rollup-freebsd-arm64": "4.63.2",
+ "@rollup/rollup-freebsd-x64": "4.63.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.2",
+ "@rollup/rollup-linux-arm64-musl": "4.63.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.2",
+ "@rollup/rollup-linux-loong64-musl": "4.63.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.2",
+ "@rollup/rollup-linux-x64-gnu": "4.63.2",
+ "@rollup/rollup-linux-x64-musl": "4.63.2",
+ "@rollup/rollup-openbsd-x64": "4.63.2",
+ "@rollup/rollup-openharmony-arm64": "4.63.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.2",
+ "@rollup/rollup-win32-x64-gnu": "4.63.2",
+ "@rollup/rollup-win32-x64-msvc": "4.63.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "license": "MIT"
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.22",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
+ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tar/node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a1dd33a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "icon-crm",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "lint": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@huggingface/transformers": "^3.8.1",
+ "@tailwindcss/vite": "^4.2.0",
+ "d3-force": "^3.0.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "tailwindcss": "^4.2.0"
+ },
+ "devDependencies": {
+ "@types/d3-force": "^3.0.10",
+ "@types/node": "^22.14.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "~5.8.2",
+ "vite": "^6.2.0"
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..b535f8c
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..ab4ef31
Binary files /dev/null and b/public/favicon.png differ
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 0000000..0580ed8
Binary files /dev/null and b/public/logo.png differ
diff --git a/public/themes/halflife-header.svg b/public/themes/halflife-header.svg
new file mode 100644
index 0000000..e505897
--- /dev/null
+++ b/public/themes/halflife-header.svg
@@ -0,0 +1,40 @@
+
diff --git a/public/themes/halflife-sidebar.svg b/public/themes/halflife-sidebar.svg
new file mode 100644
index 0000000..d6016e8
--- /dev/null
+++ b/public/themes/halflife-sidebar.svg
@@ -0,0 +1,26 @@
+
diff --git a/public/themes/pixel-header.svg b/public/themes/pixel-header.svg
new file mode 100644
index 0000000..7e070f9
--- /dev/null
+++ b/public/themes/pixel-header.svg
@@ -0,0 +1,31 @@
+
diff --git a/public/themes/pixel-sidebar.svg b/public/themes/pixel-sidebar.svg
new file mode 100644
index 0000000..e0173e4
--- /dev/null
+++ b/public/themes/pixel-sidebar.svg
@@ -0,0 +1,33 @@
+
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..4c54264
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,54 @@
+# ICON CRM Backend
+# Als .env kopieren und Werte setzen. .env gehoert nicht ins Repository.
+
+PORT=5007
+# Nur lokal binden - von aussen erreichbar ist die Anwendung ausschliesslich
+# ueber den Apache-Reverse-Proxy auf icrm.icon-experts.de.
+HOST=127.0.0.1
+
+DB_HOST=localhost
+DB_PORT=3306
+DB_USER=iconcrm
+DB_PASSWORD=changeme
+DB_NAME=icon_CRM
+
+# Signiert die JWT-Sitzungen. Langer Zufallswert, z. B.:
+# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
+JWT_SECRET=changeme-langer-zufallswert
+JWT_EXPIRES_IN=12h
+
+# Wird beim ersten Start angelegt, solange noch kein Benutzer existiert.
+# Ohne ADMIN_PASSWORD erzeugt das Backend eines und schreibt es einmalig ins Log.
+ADMIN_USERNAME=Admin
+ADMIN_NAME=Administrator
+ADMIN_EMAIL=admin@icon-experts.de
+ADMIN_PASSWORD=
+
+# Erlaubte Herkunft fuer den lokalen Vite-Dev-Server.
+CORS_ORIGINS=http://localhost:3101,http://127.0.0.1:3101
+
+# Ablage der hochgeladenen Lebenslaeufe. Leer = /../uploads.
+UPLOAD_DIR=/var/www/icon_CRM/uploads
+
+# ---------------------------------------------------------------------------
+# KI (CV-Auslesung und Schreibhilfe fuer Ausschreibungen)
+# ---------------------------------------------------------------------------
+# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
+# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus.
+#
+# Hinweis zum Modell: gpt-5.6-luna akzeptiert KEIN eigenes "temperature";
+# ai.js schickt deshalb nur den Standardwert.
+OPENAI_API_KEY=
+OPENAI_MODEL=gpt-5.6-luna
+
+# ---------------------------------------------------------------------------
+# Lesender Zugriff auf die Kontakte des perinco CRM
+# ---------------------------------------------------------------------------
+# Das ICON CRM zeigt die perinco-Kontakte zusaetzlich an - gekennzeichnet und
+# ausschliesslich lesend. Gespeichert wird nichts.
+#
+# Der Token steht drueben in server/.env als ICON_API_TOKEN und muss
+# uebereinstimmen. Fehlt einer der beiden Werte, bleibt der Schalter in der
+# Oberflaeche aus und das CRM laeuft normal weiter.
+PERINCO_API_URL=https://pcrm.perinco.de
+PERINCO_API_TOKEN=
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..332982e
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "icon-crm-backend",
+ "version": "1.0.0",
+ "private": true,
+ "description": "REST-Backend fuer das ICON CRM (MariaDB icon_CRM)",
+ "main": "src/server.js",
+ "type": "module",
+ "scripts": {
+ "start": "node src/server.js",
+ "dev": "node --watch src/server.js"
+ },
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "jsonwebtoken": "^9.0.2",
+ "mammoth": "^1.8.0",
+ "multer": "^2.0.2",
+ "mysql2": "^3.11.5",
+ "pdf-parse": "^1.1.1"
+ }
+}
diff --git a/server/src/ai.js b/server/src/ai.js
new file mode 100644
index 0000000..ba3834c
--- /dev/null
+++ b/server/src/ai.js
@@ -0,0 +1,195 @@
+import { getSetting, setSetting } from './db.js';
+
+/*
+ * Anbindung an OpenAI.
+ *
+ * Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und
+ * `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss.
+ *
+ * Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage
+ * laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt
+ * ("sk-proj-...a1b2").
+ *
+ * Er kann an zwei Stellen stehen:
+ *
+ * 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI
+ * 2. in server/.env als OPENAI_API_KEY
+ *
+ * Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt,
+ * und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen
+ * Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das
+ * System ohne Oberflaeche in Gang zu bringen.
+ */
+
+const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
+
+/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
+const TIMEOUT_MS = 120_000;
+
+export const DEFAULT_MODEL = 'gpt-5.6-luna';
+
+const KEY_SETTING = 'openai_api_key';
+const MODEL_SETTING = 'openai_model';
+
+/*
+ * Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt
+ * beim Start (loadAiConfig) und bei jeder Aenderung.
+ */
+const config = {
+ key: process.env.OPENAI_API_KEY || '',
+ model: process.env.OPENAI_MODEL || DEFAULT_MODEL,
+ /** Woher der gueltige Schluessel stammt - fuer die Anzeige. */
+ source: process.env.OPENAI_API_KEY ? 'env' : 'none',
+};
+
+/** Beim Start aufrufen, sobald die Datenbank steht. */
+export async function loadAiConfig() {
+ try {
+ const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]);
+ if (key) {
+ config.key = key;
+ config.source = 'db';
+ }
+ if (model) config.model = model;
+ } catch (err) {
+ // Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten.
+ console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message);
+ }
+}
+
+export function aiConfigured() {
+ return Boolean(config.key);
+}
+
+export function aiModel() {
+ return config.model;
+}
+
+/**
+ * Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt -
+ * genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen.
+ */
+export function aiStatus() {
+ return {
+ configured: aiConfigured(),
+ model: config.model,
+ defaultModel: DEFAULT_MODEL,
+ source: config.source,
+ masked: maskKey(config.key),
+ /** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */
+ envAvailable: Boolean(process.env.OPENAI_API_KEY),
+ };
+}
+
+function maskKey(key) {
+ if (!key) return null;
+ return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`;
+}
+
+/**
+ * Schluessel setzen oder entfernen.
+ *
+ * Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env
+ * einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall.
+ */
+export async function saveAiKey(key) {
+ const value = String(key ?? '').trim();
+ await setSetting(KEY_SETTING, value || null);
+ if (value) {
+ config.key = value;
+ config.source = 'db';
+ } else {
+ config.key = process.env.OPENAI_API_KEY || '';
+ config.source = config.key ? 'env' : 'none';
+ }
+}
+
+/** Modell setzen; leer heisst zurueck zur Vorgabe. */
+export async function saveAiModel(model) {
+ const value = String(model ?? '').trim();
+ await setSetting(MODEL_SETTING, value || null);
+ config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
+}
+
+class AiError extends Error {
+ constructor(message, status = 502) {
+ super(message);
+ this.status = status;
+ }
+}
+
+/**
+ * Ein Aufruf an das Modell.
+ *
+ * `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den
+ * Standardwert zu und weist jeden anderen mit 400 ab.
+ *
+ * @param {object} options
+ * @param {string} options.system Rollenanweisung
+ * @param {string} options.user Eigentliche Anfrage
+ * @param {boolean} [options.json] true erzwingt eine JSON-Antwort
+ * @param {number} [options.maxTokens]
+ * @returns {Promise} der Antworttext
+ */
+export async function askModel({ system, user, json = false, maxTokens }) {
+ if (!aiConfigured()) {
+ throw new AiError(
+ 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).',
+ 503
+ );
+ }
+
+ const body = {
+ model: config.model,
+ messages: [
+ ...(system ? [{ role: 'system', content: system }] : []),
+ { role: 'user', content: user },
+ ],
+ };
+ if (json) body.response_format = { type: 'json_object' };
+ if (maxTokens) body.max_completion_tokens = maxTokens;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+
+ let response;
+ try {
+ response = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${config.key}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ } catch (err) {
+ if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
+ throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
+ } finally {
+ clearTimeout(timer);
+ }
+
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
+ // sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel.
+ throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
+ }
+
+ const text = payload?.choices?.[0]?.message?.content;
+ if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
+ return text;
+}
+
+/** Wie askModel, gibt aber das geparste JSON zurueck. */
+export async function askModelJson(options) {
+ const text = await askModel({ ...options, json: true });
+ try {
+ return JSON.parse(text);
+ } catch {
+ throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
+ }
+}
+
+export { AiError };
diff --git a/server/src/cvPhoto.js b/server/src/cvPhoto.js
new file mode 100644
index 0000000..ee54660
--- /dev/null
+++ b/server/src/cvPhoto.js
@@ -0,0 +1,280 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import zlib from 'node:zlib';
+
+/*
+ * Das Bewerbungsfoto aus einem Lebenslauf holen.
+ *
+ * Viele Lebenslaeufe tragen oben rechts ein Portrait. Es von Hand nachzupflegen
+ * ist Fleissarbeit, die niemand macht - danach steht im CRM eine Kachelwand aus
+ * Initialen, obwohl das Foto in der Datei lag.
+ *
+ * Bewusst ohne zusaetzliches Paket:
+ *
+ * DOCX ist ein ZIP. Gebraucht wird genau eine Sache - die Dateien unter
+ * `word/media/` herausloesen. Das leistet das eingebaute zlib
+ * (`inflateRawSync`) zusammen mit dem Lesen des Zentralverzeichnisses.
+ * PDF speichert JPEG-Bilder mit `/DCTDecode` unveraendert im Datenstrom.
+ * Sie lassen sich deshalb an ihren eigenen Markierungen (FFD8 … FFD9)
+ * aus der Datei schneiden. Bilder in anderen Kodierungen (FlateDecode,
+ * also rohe Bitmaps) bleiben aussen vor: sie muessten neu kodiert
+ * werden, und dafuer braeuchte es eine Bildbibliothek.
+ *
+ * Verkleinert wird hier NICHT. Das macht der Browser beim Uebernehmen - dort
+ * liegt schon die Skalierung fuer alle anderen Fotos (src/lib/imageCompress.ts),
+ * und ein zweiter Weg mit eigener Bibliothek auf dem Server waere eine zweite
+ * Stelle, an der sich Ergebnisse unterscheiden koennen.
+ */
+
+/** Groesser wird nicht uebernommen - das waere ein Scan der ganzen Seite. */
+const MAX_BYTES = 6 * 1024 * 1024;
+
+/** Kleiner ist kein Portrait, sondern ein Symbol, ein Logo oder eine Linie. */
+const MIN_SIDE = 100;
+
+/**
+ * Sucht das wahrscheinlichste Portrait in der Datei.
+ *
+ * @param {string} filePath absoluter Pfad
+ * @param {string} originalName fuer die Endung
+ * @returns {Promise<{dataUrl: string, mime: string, width: number, height: number, bytes: number} | null>}
+ */
+export async function extractPhoto(filePath, originalName) {
+ const ext = path.extname(originalName || filePath).toLowerCase();
+
+ let candidates = [];
+ try {
+ const buffer = await fs.readFile(filePath);
+ if (ext === '.docx') candidates = imagesFromDocx(buffer);
+ else if (ext === '.pdf') candidates = imagesFromPdf(buffer);
+ } catch {
+ // Ein unleserliches Bild darf den Import nicht aufhalten - der Text ist
+ // das Wesentliche, das Foto die Zugabe.
+ return null;
+ }
+
+ const best = pickPortrait(candidates);
+ if (!best) return null;
+
+ return {
+ dataUrl: `data:${best.mime};base64,${best.data.toString('base64')}`,
+ mime: best.mime,
+ width: best.width,
+ height: best.height,
+ bytes: best.data.length,
+ };
+}
+
+/**
+ * Welches der gefundenen Bilder ist das Portrait?
+ *
+ * Die Reihenfolge in der Datei hilft nicht: in DOCX steht das Firmenlogo oft
+ * vor dem Foto. Entschieden wird deshalb ueber die Form.
+ *
+ * * zu klein - Symbol, Trennlinie, Aufzaehlungszeichen
+ * * sehr breit oder flach - Kopfzeile, Banner, Diagramm
+ * * hochkant bis leicht quer bleibt uebrig; davon das groesste
+ *
+ * Hochformat gewinnt gegen Querformat, auch wenn es kleiner ist: ein Portrait
+ * ist so gut wie immer hoeher als breit, ein Banner nie.
+ */
+function pickPortrait(images) {
+ const usable = images.filter((image) => {
+ if (!image.width || !image.height) return false;
+ if (image.data.length > MAX_BYTES) return false;
+ if (image.width < MIN_SIDE || image.height < MIN_SIDE) return false;
+ const ratio = image.width / image.height;
+ return ratio >= 0.4 && ratio <= 1.6;
+ });
+
+ if (usable.length === 0) return null;
+
+ return usable.sort((a, b) => {
+ const portraitA = a.height >= a.width ? 1 : 0;
+ const portraitB = b.height >= b.width ? 1 : 0;
+ if (portraitA !== portraitB) return portraitB - portraitA;
+ return b.width * b.height - a.width * a.height;
+ })[0];
+}
+
+/* ----------------------------------------------------------------- DOCX */
+
+/**
+ * Die Bilder unter `word/media/` aus dem ZIP holen.
+ *
+ * Gelesen wird das Zentralverzeichnis am Dateiende - nur dort stehen die
+ * Namen zuverlaessig. Die lokalen Kopfsaetze davor koennen Groessenangaben
+ * nachreichen (Bit 3 der Flags), das Zentralverzeichnis nie.
+ */
+function imagesFromDocx(buffer) {
+ const images = [];
+ const endOffset = findEndOfCentralDirectory(buffer);
+ if (endOffset < 0) return images;
+
+ const entryCount = buffer.readUInt16LE(endOffset + 10);
+ let offset = buffer.readUInt32LE(endOffset + 16);
+
+ for (let index = 0; index < entryCount; index += 1) {
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) break;
+
+ const method = buffer.readUInt16LE(offset + 10);
+ const compressedSize = buffer.readUInt32LE(offset + 20);
+ const nameLength = buffer.readUInt16LE(offset + 28);
+ const extraLength = buffer.readUInt16LE(offset + 30);
+ const commentLength = buffer.readUInt16LE(offset + 32);
+ const localOffset = buffer.readUInt32LE(offset + 42);
+ const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
+
+ offset += 46 + nameLength + extraLength + commentLength;
+
+ if (!/^word\/media\//i.test(name)) continue;
+ if (compressedSize === 0 || compressedSize > MAX_BYTES) continue;
+
+ const data = readLocalEntry(buffer, localOffset, method, compressedSize);
+ if (!data) continue;
+
+ const info = describeImage(data);
+ if (info) images.push({ ...info, data });
+ }
+
+ return images;
+}
+
+/** Nutzdaten eines Eintrags ab seinem lokalen Kopfsatz. */
+function readLocalEntry(buffer, localOffset, method, compressedSize) {
+ if (localOffset + 30 > buffer.length) return null;
+ if (buffer.readUInt32LE(localOffset) !== 0x04034b50) return null;
+
+ const nameLength = buffer.readUInt16LE(localOffset + 26);
+ const extraLength = buffer.readUInt16LE(localOffset + 28);
+ const start = localOffset + 30 + nameLength + extraLength;
+ const slice = buffer.subarray(start, start + compressedSize);
+
+ try {
+ if (method === 0) return Buffer.from(slice); // unkomprimiert abgelegt
+ if (method === 8) return zlib.inflateRawSync(slice);
+ } catch {
+ return null;
+ }
+ return null;
+}
+
+/** Das "End of Central Directory"-Kennzeichen steht am Dateiende. */
+function findEndOfCentralDirectory(buffer) {
+ // Der Kommentar am Ende darf 64 KB lang sein - weiter zurueck lohnt nicht.
+ const from = Math.max(0, buffer.length - 22 - 0xffff);
+ for (let index = buffer.length - 22; index >= from; index -= 1) {
+ if (buffer.readUInt32LE(index) === 0x06054b50) return index;
+ }
+ return -1;
+}
+
+/* ------------------------------------------------------------------ PDF */
+
+/**
+ * JPEG-Bilder aus einem PDF schneiden.
+ *
+ * Mit `/DCTDecode` abgelegte Bilder stehen unveraendert in der Datei; ihre
+ * eigenen Markierungen begrenzen sie zuverlaessiger als ein halber
+ * PDF-Parser. Was dabei kein gueltiges JPEG ergibt, faellt bei der
+ * Groessenpruefung ohnehin durch.
+ */
+function imagesFromPdf(buffer) {
+ const images = [];
+ let index = 0;
+
+ while (index < buffer.length - 3) {
+ // Start of Image, gefolgt von einer Markierung (FFE0-FFEF oder FFDB).
+ if (buffer[index] === 0xff && buffer[index + 1] === 0xd8 && buffer[index + 2] === 0xff) {
+ const end = findJpegEnd(buffer, index + 2);
+ if (end > index) {
+ const data = buffer.subarray(index, end);
+ const info = describeImage(data);
+ if (info) images.push({ ...info, data: Buffer.from(data) });
+ index = end;
+ continue;
+ }
+ }
+ index += 1;
+ }
+
+ return images;
+}
+
+/** Laeuft die Markierungen entlang bis zum "End of Image". */
+function findJpegEnd(buffer, start) {
+ let index = start;
+ while (index < buffer.length - 1) {
+ if (buffer[index] !== 0xff) {
+ index += 1;
+ continue;
+ }
+ const marker = buffer[index + 1];
+ if (marker === 0xd9) return index + 2; // EOI
+ if (marker === 0xda) {
+ // Beginn der Bilddaten: darin stehen beliebige Bytes, nur FF00 und die
+ // Restart-Markierungen sind erlaubt. Bis zum naechsten echten Marker.
+ index += 2;
+ while (index < buffer.length - 1) {
+ if (buffer[index] === 0xff && buffer[index + 1] !== 0x00 &&
+ !(buffer[index + 1] >= 0xd0 && buffer[index + 1] <= 0xd7)) {
+ break;
+ }
+ index += 1;
+ }
+ continue;
+ }
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
+ index += 2;
+ continue;
+ }
+ if (index + 3 >= buffer.length) return -1;
+ index += 2 + buffer.readUInt16BE(index + 2);
+ }
+ return -1;
+}
+
+/* --------------------------------------------------------------- Format */
+
+/**
+ * Art und Abmessungen eines Bildes - ohne es zu dekodieren.
+ *
+ * Gebraucht werden sie fuer die Auswahl: ohne Abmessungen liesse sich ein
+ * Portrait nicht von einem Logo unterscheiden.
+ */
+function describeImage(data) {
+ if (data.length < 24) return null;
+
+ // PNG: Abmessungen stehen im IHDR direkt am Anfang.
+ if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
+ return { mime: 'image/png', width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
+ }
+
+ // JPEG: die Abmessungen stehen im Start-of-Frame (C0-CF, ohne C4, C8, CC).
+ if (data[0] === 0xff && data[1] === 0xd8) {
+ let index = 2;
+ while (index < data.length - 9) {
+ if (data[index] !== 0xff) {
+ index += 1;
+ continue;
+ }
+ const marker = data[index + 1];
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
+ return {
+ mime: 'image/jpeg',
+ height: data.readUInt16BE(index + 5),
+ width: data.readUInt16BE(index + 7),
+ };
+ }
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
+ index += 2;
+ continue;
+ }
+ if (index + 3 >= data.length) break;
+ index += 2 + data.readUInt16BE(index + 2);
+ }
+ return null;
+ }
+
+ return null;
+}
diff --git a/server/src/cvText.js b/server/src/cvText.js
new file mode 100644
index 0000000..8e3c923
--- /dev/null
+++ b/server/src/cvText.js
@@ -0,0 +1,83 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+
+/*
+ * Text aus einem Lebenslauf holen.
+ *
+ * Unterstuetzt PDF, DOCX und reinen Text. Das alte .doc-Format (Word 97) bleibt
+ * aussen vor: es braeuchte einen eigenen Parser, und wer eine solche Datei hat,
+ * speichert sie in Sekunden als PDF.
+ *
+ * pdf-parse wird ueber den internen Pfad geladen. Der Paket-Einstieg fuehrt
+ * beim Import eine Selbstprobe aus und sucht dafuer eine Testdatei, die im
+ * npm-Paket nicht mitgeliefert wird - das laesst den Start scheitern.
+ */
+const pdfParse = require('pdf-parse/lib/pdf-parse.js');
+
+/** Mehr Text braucht kein Modell, um einen Lebenslauf zu verstehen. */
+const MAX_CHARS = 60_000;
+
+export function isSupported(filename, mime = '') {
+ const ext = path.extname(filename).toLowerCase();
+ return (
+ ['.pdf', '.docx', '.txt', '.md'].includes(ext) ||
+ mime === 'application/pdf' ||
+ mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
+ mime.startsWith('text/')
+ );
+}
+
+/**
+ * Liest die Datei und gibt ihren Text zurueck.
+ *
+ * @param {string} filePath absoluter Pfad
+ * @param {string} originalName fuer die Endung
+ */
+export async function extractText(filePath, originalName) {
+ const ext = path.extname(originalName || filePath).toLowerCase();
+
+ if (ext === '.pdf') {
+ const result = await pdfParse(await fs.readFile(filePath));
+ return clean(result.text);
+ }
+
+ if (ext === '.docx') {
+ // mammoth liefert den Fliesstext ohne Formatierung - genau das, was das
+ // Modell braucht.
+ const mammoth = require('mammoth');
+ const result = await mammoth.extractRawText({ path: filePath });
+ return clean(result.value);
+ }
+
+ if (ext === '.txt' || ext === '.md') {
+ return clean(await fs.readFile(filePath, 'utf8'));
+ }
+
+ throw Object.assign(new Error('Dateityp wird nicht unterstützt (PDF, DOCX oder Text).'), {
+ status: 400,
+ });
+}
+
+/*
+ * Aufraeumen: PDF-Auszuege enthalten viele harte Zeilenumbrueche und
+ * Mehrfachleerzeichen aus dem Satzspiegel. Das kostet nur Token und erschwert
+ * dem Modell das Lesen.
+ */
+function clean(text) {
+ const result = String(text ?? '')
+ .replace(/\r/g, '')
+ .replace(/[ \t]+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+
+ if (result.length <= MAX_CHARS) return result;
+
+ // Anfang und Ende behalten: vorn stehen Name und Kontaktdaten, hinten oft
+ // Ausbildung und Zertifikate.
+ const head = result.slice(0, Math.floor(MAX_CHARS * 0.7));
+ const tail = result.slice(-Math.floor(MAX_CHARS * 0.3));
+ return `${head}\n\n[… gekürzt …]\n\n${tail}`;
+}
diff --git a/server/src/db.js b/server/src/db.js
new file mode 100644
index 0000000..9a13651
--- /dev/null
+++ b/server/src/db.js
@@ -0,0 +1,200 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { randomBytes } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import mysql from 'mysql2/promise';
+import bcrypt from 'bcryptjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export const pool = mysql.createPool({
+ host: process.env.DB_HOST || 'localhost',
+ port: Number(process.env.DB_PORT || 3306),
+ user: process.env.DB_USER || 'root',
+ password: process.env.DB_PASSWORD || '',
+ database: process.env.DB_NAME || 'icon_CRM',
+ waitForConnections: true,
+ connectionLimit: 8,
+ queueLimit: 0,
+ charset: 'utf8mb4',
+ // Datumswerte als Zeichenkette: sonst rechnet der Treiber sie in die
+ // Zeitzone des Prozesses um und ein DATE verschiebt sich um einen Tag.
+ dateStrings: true,
+});
+
+/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */
+export async function initDatabase() {
+ const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8');
+
+ // schema.sql enthaelt mehrere Statements. Statt multipleStatements zu
+ // aktivieren (was SQL-Injection deutlich gefaehrlicher machen wuerde), wird
+ // an Semikolons am Zeilenende getrennt. Kommentarzeilen fliegen vorher raus,
+ // sonst bestuende ein Statement nach dem Split nur aus Kommentar.
+ const statements = sql
+ .split('\n')
+ .filter((line) => !line.trim().startsWith('--'))
+ .join('\n')
+ .split(/;\s*$/m)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+
+ const conn = await pool.getConnection();
+ try {
+ for (const statement of statements) {
+ await conn.query(statement);
+ }
+ } finally {
+ conn.release();
+ }
+
+ await migrateColumns();
+ await migrateContactKind();
+ await seedOptions();
+ await ensureAdminUser();
+}
+
+/*
+ * Die Kontaktart war ein ENUM mit drei festen Werten. Damit sie sich in den
+ * Einstellungen pflegen laesst, wird daraus ein Textfeld - die erlaubten Werte
+ * stehen jetzt in icon_options.
+ *
+ * Erkannt wird der Altstand am Spaltentyp. Ein ENUM zu erweitern haette bei
+ * jeder neuen Art eine Migration bedeutet.
+ */
+async function migrateContactKind() {
+ const [rows] = await pool.query(
+ `SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'icon_contacts'
+ AND COLUMN_NAME = 'kind' LIMIT 1`
+ );
+ if (rows[0]?.DATA_TYPE !== 'enum') return;
+
+ await pool.query("ALTER TABLE icon_contacts MODIFY kind VARCHAR(60) NOT NULL DEFAULT 'Experte'");
+ console.log('[ICON CRM] Kontaktart ist jetzt eine gepflegte Auswahlliste.');
+}
+
+/*
+ * Startbestand der Auswahllisten: die Werte, die bis zur Umstellung im
+ * Quelltext standen, plus alles, was in vorhandenen Datensaetzen schon als
+ * Freitext steht.
+ *
+ * Laeuft genau einmal, festgehalten per Merker. Ohne den waere jeder Neustart
+ * ein Wiederherstellen: eine Branche, die jemand bewusst entfernt hat, waere
+ * danach wieder da.
+ */
+async function seedOptions() {
+ if ((await getSetting('options_seeded')) === 'true') return;
+
+ const kinds = ['Experte', 'Ansprechpartner', 'Lead'];
+ await pool.query(
+ `INSERT IGNORE INTO icon_options (field, value, position)
+ VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`,
+ kinds
+ );
+
+ // Vorhandene Freitexte uebernehmen, damit kein Unternehmen eine Branche
+ // traegt, die sein Auswahlfeld nicht kennt.
+ await pool.query(
+ `INSERT IGNORE INTO icon_options (field, value)
+ SELECT 'industry', TRIM(industry) FROM icon_companies
+ WHERE industry IS NOT NULL AND TRIM(industry) <> ''
+ GROUP BY TRIM(industry)`
+ );
+ await pool.query(
+ `INSERT IGNORE INTO icon_options (field, value)
+ SELECT 'contact_kind', TRIM(kind) FROM icon_contacts
+ WHERE kind IS NOT NULL AND TRIM(kind) <> ''
+ GROUP BY TRIM(kind)`
+ );
+
+ await setSetting('options_seeded', 'true');
+ const [[count]] = await pool.query('SELECT COUNT(*) AS count FROM icon_options');
+ console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`);
+}
+
+/*
+ * Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind.
+ *
+ * `CREATE TABLE IF NOT EXISTS` in schema.sql fasst eine vorhandene Tabelle
+ * nicht an - Nachtraege gehoeren deshalb hierher. Jeder Eintrag prueft erst
+ * INFORMATION_SCHEMA und ist damit bei jedem weiteren Start ein Blick in den
+ * Katalog und sonst nichts.
+ */
+async function migrateColumns() {
+ const additions = [
+ // Hauptansprechpartner eines Unternehmens - Auswahl aus den zugeordneten
+ // Kontakten (Featureliste, Abschnitt "Kunden").
+ [
+ 'icon_companies',
+ 'primary_contact_id',
+ `ALTER TABLE icon_companies
+ ADD COLUMN primary_contact_id INT UNSIGNED NULL AFTER owner_id,
+ ADD CONSTRAINT fk_icon_companies_primary FOREIGN KEY (primary_contact_id)
+ REFERENCES icon_contacts (id) ON DELETE SET NULL`,
+ ],
+ // Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
+ // und Uebernehmen (siehe server/src/cvPhoto.js).
+ [
+ 'icon_cv_imports',
+ 'photo',
+ 'ALTER TABLE icon_cv_imports ADD COLUMN photo MEDIUMTEXT NULL AFTER extracted',
+ ],
+ ];
+
+ for (const [table, column, statement] of additions) {
+ const [rows] = await pool.query(
+ `SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
+ [table, column]
+ );
+ if (rows.length > 0) continue;
+
+ await pool.query(statement);
+ console.log(`[ICON CRM] Spalte ${table}.${column} ergänzt.`);
+ }
+}
+
+/** Liest einen Wert aus icon_settings. */
+export async function getSetting(key) {
+ const [rows] = await pool.query('SELECT value FROM icon_settings WHERE key_name = ? LIMIT 1', [key]);
+ return rows[0]?.value ?? null;
+}
+
+/** Schreibt einen Wert nach icon_settings. */
+export async function setSetting(key, value) {
+ await pool.query(
+ `INSERT INTO icon_settings (key_name, value) VALUES (?, ?)
+ ON DUPLICATE KEY UPDATE value = VALUES(value)`,
+ [key, value]
+ );
+}
+
+async function ensureAdminUser() {
+ const [rows] = await pool.query('SELECT COUNT(*) AS count FROM icon_users');
+ if (rows[0].count > 0) return;
+
+ const username = process.env.ADMIN_USERNAME || 'Admin';
+ const name = process.env.ADMIN_NAME || 'Administrator';
+ const email = process.env.ADMIN_EMAIL || null;
+
+ // Kein festes Ersatzpasswort im Quelltext: ohne ADMIN_PASSWORD wird ein
+ // Zufallswert erzeugt und einmalig ins Log geschrieben. Ein bekannter
+ // Standardwert waere auf jeder Installation derselbe.
+ const generated = !process.env.ADMIN_PASSWORD;
+ const password = process.env.ADMIN_PASSWORD || randomBytes(12).toString('base64url');
+ const hash = await bcrypt.hash(password, 12);
+
+ await pool.query(
+ `INSERT INTO icon_users (username, email, name, role, password_hash, is_active)
+ VALUES (?, ?, ?, 'admin', ?, 1)`,
+ [username, email, name, hash]
+ );
+
+ console.log(`[ICON CRM] Admin-Benutzer "${username}" wurde initial angelegt.`);
+ if (generated) {
+ console.log(
+ `[ICON CRM] ADMIN_PASSWORD war nicht gesetzt - erzeugtes Passwort: ${password}\n` +
+ ' Jetzt notieren und in der Anwendung unter "Mein Konto" aendern.'
+ );
+ }
+}
diff --git a/server/src/history.js b/server/src/history.js
new file mode 100644
index 0000000..ea5a896
--- /dev/null
+++ b/server/src/history.js
@@ -0,0 +1,176 @@
+import { pool } from './db.js';
+
+/*
+ * Historie und Erwaehnungen.
+ *
+ * Jede schreibende Route ruft hier herein, statt selbst in icon_history zu
+ * schreiben. Damit gilt an einer Stelle, was die Featureliste verlangt: in
+ * jeder Historie steht, welcher Benutzer was geaendert hat, und jedes @ im
+ * Text taucht als eigener Eintrag auf.
+ */
+
+/**
+ * Mention-Token im Text: `@[Anzeigename](contact:12)`.
+ *
+ * Der Name steht mit im Token, damit ein Text auch ohne geladene Listen
+ * lesbar bleibt; beim Rendern gewinnt der aktuelle Name aus den Daten.
+ */
+const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g;
+
+export function extractMentions(text) {
+ if (!text) return [];
+ const found = new Map();
+ MENTION_TOKEN.lastIndex = 0;
+ let match;
+ while ((match = MENTION_TOKEN.exec(String(text))) !== null) {
+ const key = `${match[2]}:${match[3]}`;
+ if (!found.has(key)) found.set(key, { type: match[2], id: Number(match[3]), label: match[1] });
+ }
+ return [...found.values()];
+}
+
+/** Text ohne Token-Syntax - fuer Suche und Vorschauen. */
+export function mentionPlainText(text) {
+ if (!text) return '';
+ return String(text).replace(MENTION_TOKEN, (_all, label) => `@${label}`);
+}
+
+/**
+ * Schreibt einen Historieneintrag und indiziert die darin enthaltenen
+ * Erwaehnungen.
+ *
+ * @param {object} entry
+ * @param {'contact'|'company'|'tender'|'project'|'lead'|'user'|'system'} entry.entityType
+ * @param {number|null} entry.entityId
+ * @param {string|null} entry.entityLabel Name zum Zeitpunkt des Eintrags
+ * @param {string} entry.action created | updated | deleted | note | ...
+ * @param {string|null} entry.text
+ * @param {object|null} entry.changes { feld: [vorher, nachher] }
+ * @param {number|null} entry.userId
+ * @returns {Promise} id des Eintrags
+ */
+export async function logHistory({
+ entityType,
+ entityId = null,
+ entityLabel = null,
+ action = 'note',
+ text = null,
+ changes = null,
+ userId = null,
+}) {
+ const [result] = await pool.query(
+ `INSERT INTO icon_history (entity_type, entity_id, entity_label, action, text, changes, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [
+ entityType,
+ entityId,
+ entityLabel ? String(entityLabel).slice(0, 300) : null,
+ action,
+ text ?? null,
+ changes && Object.keys(changes).length > 0 ? JSON.stringify(changes) : null,
+ userId,
+ ]
+ );
+
+ const historyId = result.insertId;
+ await indexMentions('history', historyId, text);
+ return historyId;
+}
+
+/**
+ * Traegt die Erwaehnungen eines Textes in icon_mentions nach.
+ *
+ * Erst loeschen, dann neu schreiben: so verschwindet ein @, das aus einer
+ * Notiz wieder entfernt wurde, auch aus dem Index.
+ */
+export async function indexMentions(sourceType, sourceId, text) {
+ const mentions = extractMentions(text);
+ await pool.query('DELETE FROM icon_mentions WHERE source_type = ? AND source_id = ?', [
+ sourceType,
+ sourceId,
+ ]);
+ if (mentions.length === 0) return [];
+
+ const values = mentions.flatMap((m) => [sourceType, sourceId, m.type, m.id]);
+ await pool.query(
+ `INSERT IGNORE INTO icon_mentions (source_type, source_id, target_type, target_id)
+ VALUES ${mentions.map(() => '(?, ?, ?, ?)').join(', ')}`,
+ values
+ );
+ return mentions;
+}
+
+/**
+ * Vergleicht alten und neuen Datensatz und liefert nur die geaenderten Felder.
+ *
+ * Verglichen wird ueber String(), weil MariaDB Zahlen mal als Number und mal
+ * als String liefert (DECIMAL etwa immer als String) - ein strikter Vergleich
+ * wuerde daraus eine Scheinaenderung machen.
+ */
+export function diffFields(before, after, fields) {
+ const changes = {};
+ for (const field of fields) {
+ if (after[field] === undefined) continue;
+ const from = before?.[field] ?? null;
+ const to = after[field] ?? null;
+ if (String(from ?? '') === String(to ?? '')) continue;
+ changes[field] = [from, to];
+ }
+ return changes;
+}
+
+/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
+export const FIELD_LABELS = {
+ name: 'Name',
+ first_name: 'Vorname',
+ last_name: 'Nachname',
+ position: 'Position',
+ email: 'E-Mail',
+ phone: 'Telefon',
+ linkedin: 'LinkedIn',
+ company_id: 'Unternehmen',
+ kind: 'Art',
+ status: 'Status',
+ lead_source: 'Lead-Quelle',
+ contact_state: 'Kontaktstand',
+ freelancers: 'Freelancer-Einsatz',
+ bio: 'Notiz',
+ tags: 'Schlagworte',
+ rate_onsite: 'Stundensatz vor Ort',
+ rate_remote: 'Stundensatz remote',
+ cv_url: 'CV-Link',
+ avatar: 'Foto',
+ owner_id: 'Zustaendig',
+ group_name: 'Gruppe',
+ industry: 'Branche',
+ employees: 'Mitarbeiterzahl',
+ website: 'Website',
+ address: 'Adresse',
+ logo: 'Logo',
+ rating: 'Bewertung',
+ is_customer: 'Kunde',
+ customer_state: 'Kundenstatus',
+ notes: 'Notizen',
+ title: 'Titel',
+ source: 'Quelle',
+ link: 'Link',
+ stage: 'Phase',
+ online: 'Online',
+ details: 'Details',
+ deadline: 'Frist',
+ started_on: 'Beginn',
+ ended_on: 'Ende',
+ rate: 'Satz',
+ contact_id: 'Kontakt',
+ archived: 'Archiviert',
+ remind_at: 'Erinnerung',
+ reminder_days: 'Erinnerungsintervall',
+};
+
+/** "Status, Telefon und 2 weitere Felder geaendert" */
+export function describeChanges(changes) {
+ const labels = Object.keys(changes).map((key) => FIELD_LABELS[key] || key);
+ if (labels.length === 0) return 'ohne Änderung gespeichert';
+ if (labels.length <= 3) return `${labels.join(', ')} geändert`;
+ return `${labels.slice(0, 2).join(', ')} und ${labels.length - 2} weitere Felder geändert`;
+}
diff --git a/server/src/linkedinPhoto.js b/server/src/linkedinPhoto.js
new file mode 100644
index 0000000..4d439e8
--- /dev/null
+++ b/server/src/linkedinPhoto.js
@@ -0,0 +1,199 @@
+/*
+ * Profilfoto zu einem LinkedIn-Profil besorgen.
+ *
+ * LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit,
+ * die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com).
+ * Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein
+ * Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder
+ * blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung
+ * statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen.
+ *
+ * Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er
+ * an der Same-Origin-Policy gescheitert.
+ */
+
+/** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */
+const BROWSER_HEADERS = {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/140.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
+};
+
+/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
+const TIMEOUT_MS = 12_000;
+
+/** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */
+const MAX_IMAGE_BYTES = 5_000_000;
+
+/** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */
+const IMAGE_HOSTS = ['licdn.com'];
+
+class PhotoError extends Error {
+ constructor(message, status = 502) {
+ super(message);
+ this.status = status;
+ }
+}
+
+function hostMatches(hostname, domain) {
+ return hostname === domain || hostname.endsWith(`.${domain}`);
+}
+
+/**
+ * Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert
+ * auch "linkedin.com/in/vorname" ohne Schema.
+ */
+export function normalizeProfileUrl(input) {
+ const raw = String(input ?? '').trim();
+ if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400);
+
+ let url;
+ try {
+ url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
+ } catch {
+ throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400);
+ }
+
+ if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) {
+ throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400);
+ }
+
+ // Tracking-Parameter stoeren nur und aendern die Seite nicht.
+ url.protocol = 'https:';
+ url.search = '';
+ url.hash = '';
+ return url.toString();
+}
+
+/**
+ * Bild-URL aus dem HTML der Profilseite ziehen - ausschliesslich aus der
+ * Open-Graph-Vorschau (`og:image`, ersatzweise `twitter:image`).
+ *
+ * Bewusst wird NICHT im uebrigen Quelltext nach einer Bildadresse gesucht: eine
+ * Profilseite enthaelt Dutzende Adressen des LinkedIn-CDN - Firmenlogos,
+ * Beitragsbilder und die Fotos der "aehnlichen Profile" am Rand. Die erste
+ * davon zu nehmen, haengt irgendwann einem Kontakt das Gesicht einer fremden
+ * Person an. Nur die Vorschau gehoert nachweislich zum aufgerufenen Profil.
+ */
+function extractImageUrl(html) {
+ const metaPattern = /]+>/gi;
+ for (const tag of html.match(metaPattern) ?? []) {
+ const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag);
+ if (!isImageTag) continue;
+ const content = tag.match(/content\s*=\s*["']([^"']+)["']/i);
+ if (content?.[1]) return decodeEntities(content[1]);
+ }
+ return null;
+}
+
+/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */
+function decodeEntities(value) {
+ return value
+ .replace(/&/g, '&')
+ .replace(///gi, '/')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"');
+}
+
+async function requestWithTimeout(url, headers) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ return await fetch(url, { headers, redirect: 'follow', signal: controller.signal });
+ } catch (err) {
+ if (err?.name === 'AbortError') {
+ throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504);
+ }
+ throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein
+ * hochgeladenes Foto auch gespeichert wird.
+ */
+export async function fetchLinkedInPhoto(profileInput) {
+ const profileUrl = normalizeProfileUrl(profileInput);
+
+ const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS);
+ if (page.status === 404 || page.status === 410) {
+ throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404);
+ }
+ if (!page.ok) {
+ // 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen".
+ throw new PhotoError(
+ 'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.',
+ 502
+ );
+ }
+
+ const html = await page.text();
+ const imageUrl = extractImageUrl(html);
+ if (!imageUrl) {
+ throw new PhotoError(
+ 'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.',
+ 404
+ );
+ }
+
+ let parsedImage;
+ try {
+ parsedImage = new URL(imageUrl);
+ } catch {
+ throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502);
+ }
+ const isTrustedHost =
+ parsedImage.protocol === 'https:' &&
+ IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain));
+ if (!isTrustedHost) {
+ throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502);
+ }
+
+ /*
+ * Echte Profilfotos liegen unter media.licdn.com/dms/image/... - alles andere
+ * ist LinkedIns Ersatzbild: die graue Silhouette ('ghost') oder eine feste
+ * Grafik von static.licdn.com (/aero-v1/sc/h/...). Das bekommt zu sehen, wer
+ * nicht angemeldet ist und dessen Profilfoto nur fuer Kontakte sichtbar ist -
+ * von aussen laesst sich das nicht von "gar kein Foto" unterscheiden, deshalb
+ * nennt die Meldung beide Moeglichkeiten.
+ */
+ const path = parsedImage.pathname.toLowerCase();
+ if (path.includes('ghost') || !path.includes('/dms/image/')) {
+ throw new PhotoError(
+ 'LinkedIn gibt zu diesem Profil kein Foto heraus: Es ist nur für Kontakte ' +
+ 'sichtbar oder es gibt keines. Bitte das Foto manuell hochladen.',
+ 404
+ );
+ }
+
+ const image = await requestWithTimeout(parsedImage.toString(), {
+ 'User-Agent': BROWSER_HEADERS['User-Agent'],
+ 'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8',
+ 'Referer': profileUrl,
+ });
+ if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502);
+
+ const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
+ if (!contentType.startsWith('image/')) {
+ throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502);
+ }
+
+ const buffer = Buffer.from(await image.arrayBuffer());
+ if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502);
+ if (buffer.length > MAX_IMAGE_BYTES) {
+ throw new PhotoError('Das Profilfoto ist zu groß.', 502);
+ }
+
+ return {
+ avatar: `data:${contentType};base64,${buffer.toString('base64')}`,
+ bytes: buffer.length,
+ source_url: parsedImage.toString(),
+ profile_url: profileUrl,
+ };
+}
+
+export { PhotoError, extractImageUrl, decodeEntities };
diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js
new file mode 100644
index 0000000..97e24f4
--- /dev/null
+++ b/server/src/middleware/auth.js
@@ -0,0 +1,62 @@
+import jwt from 'jsonwebtoken';
+import { pool } from '../db.js';
+
+function secret() {
+ const value = process.env.JWT_SECRET;
+ if (!value || value.length < 16) {
+ throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).');
+ }
+ return value;
+}
+
+export function signToken(user) {
+ return jwt.sign({ sub: user.id, role: user.role }, secret(), {
+ expiresIn: process.env.JWT_EXPIRES_IN || '12h',
+ });
+}
+
+/**
+ * Prueft das Bearer-Token und laedt den aktuellen Benutzer aus der Datenbank.
+ *
+ * Die Spaltenliste ist bewusst vollstaendig aufgezaehlt und kein SELECT *:
+ * das Passwort-Hash soll nicht am Request haengen. Sie muss aber alles
+ * enthalten, was publicUser() ausliefert - sonst faellt beim Neuladen z. B.
+ * die Theme-Wahl auf "nichts gewaehlt" zurueck.
+ */
+export async function requireAuth(req, res, next) {
+ const header = req.headers.authorization || '';
+ const token = header.startsWith('Bearer ') ? header.slice(7) : null;
+ if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' });
+
+ let payload;
+ try {
+ payload = jwt.verify(token, secret());
+ } catch {
+ return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
+ }
+
+ try {
+ const [rows] = await pool.query(
+ `SELECT id, username, email, name, role, color, avatar, theme, theme_mode,
+ is_active, created_at, updated_at
+ FROM icon_users WHERE id = ? LIMIT 1`,
+ [payload.sub]
+ );
+ const user = rows[0];
+ if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' });
+ if (!user.is_active) return res.status(403).json({ error: 'Benutzerkonto ist deaktiviert.' });
+
+ req.user = { ...user, is_active: Boolean(user.is_active) };
+ next();
+ } catch (err) {
+ next(err);
+ }
+}
+
+/** Nur Rolle admin - schuetzt Einstellungen und Nutzerverwaltung. */
+export function requireAdmin(req, res, next) {
+ if (req.user?.role !== 'admin') {
+ return res.status(403).json({ error: 'Diese Aktion ist Administratoren vorbehalten.' });
+ }
+ next();
+}
diff --git a/server/src/migrate.js b/server/src/migrate.js
new file mode 100644
index 0000000..0db89c5
--- /dev/null
+++ b/server/src/migrate.js
@@ -0,0 +1,388 @@
+import 'dotenv/config';
+import { pool, initDatabase } from './db.js';
+
+/*
+ * Einmalige Uebernahme der Daten aus dem alten ICON CRM (Supabase,
+ * icondb.perinco.de) in die neue MariaDB.
+ *
+ * Aufruf auf dem Server:
+ * cd /var/www/icon_CRM/server
+ * SUPABASE_URL=... SUPABASE_KEY=... node src/migrate.js # Probelauf
+ * SUPABASE_URL=... SUPABASE_KEY=... node src/migrate.js --write # schreibt
+ *
+ * Der Probelauf ist die Voreinstellung: er liest, rechnet die Zuordnung aus
+ * und zeigt, was passieren wuerde - ohne eine Zeile zu schreiben.
+ *
+ * Wiederholbar: Unternehmen und Kontakte werden ueber ihren Namen wiedererkannt,
+ * ein zweiter Lauf legt sie nicht erneut an.
+ */
+
+const BASE = (process.env.SUPABASE_URL || 'https://icondb.perinco.de').replace(/\/+$/, '');
+const KEY = process.env.SUPABASE_KEY || '';
+const WRITE = process.argv.includes('--write');
+
+const TABLES = {
+ customers: 'icon_customers',
+ contacts: 'icon_contacts',
+ experts: 'icon_experts',
+ tenders: 'icon_tenders',
+ tenderExperts: 'icon_tender_experts',
+ projects: 'icon_projects',
+ users: 'icon_users',
+};
+
+/** Status des alten Systems auf den neuen abbilden. */
+const STATUS_MAP = {
+ Interessiert: 'Interessiert',
+ Interview: 'Interview',
+ Gebucht: 'Gebucht',
+ // Der Verfuegbarkeitsstatus ist entfallen - diese Kontakte landen auf
+ // "Interessiert", der neutralen Ausgangslage.
+ 'Verfügbar': 'Interessiert',
+};
+
+const STAGE_MAP = {
+ Ausschreibung: 'Ausschreibung',
+ 'Experten sichten': 'Kontakte sichten',
+ Interview: 'Interview',
+ 'Kunden Interview': 'Kunden Interview',
+ Vertragsgestaltung: 'Vertragsgestaltung',
+ Abschluss: 'Abschluss',
+ Geschlossen: 'Geschlossen',
+};
+
+const TENDER_STATUS = new Set(['Aktiv', 'Inaktiv', 'Ausgelaufen', 'Archiviert']);
+const PROJECT_STATUS = new Set(['Aktiv', 'Nicht aktiv', 'Abgeschlossen']);
+
+async function fetchTable(table) {
+ if (!KEY) throw new Error('SUPABASE_KEY fehlt - ohne Schluessel liefert PostgREST nichts.');
+ const response = await fetch(`${BASE}/rest/v1/${table}?select=*`, {
+ headers: { apikey: KEY, Authorization: `Bearer ${KEY}` },
+ });
+ if (!response.ok) {
+ throw new Error(`${table}: HTTP ${response.status} ${await response.text().catch(() => '')}`);
+ }
+ return response.json();
+}
+
+/** "Hans Peter Meier" -> { first_name: 'Hans Peter', last_name: 'Meier' } */
+function splitName(value) {
+ const parts = String(value ?? '').trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return { first_name: null, last_name: 'Unbenannt' };
+ if (parts.length === 1) return { first_name: null, last_name: parts[0] };
+ return { first_name: parts.slice(0, -1).join(' '), last_name: parts[parts.length - 1] };
+}
+
+function clean(value, max) {
+ const text = String(value ?? '').trim();
+ return text ? text.slice(0, max) : null;
+}
+
+function money(value) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+function isoDate(value) {
+ const text = String(value ?? '').slice(0, 10);
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
+}
+
+/*
+ * Bilder aus dem Altbestand koennen alles sein: Data-URL, http-Adresse oder
+ * Unsinn. Alles, was das Backend spaeter ablehnen wuerde, faellt hier weg -
+ * ein Kontakt ohne Foto ist besser als ein Datensatz, der nicht mehr
+ * speicherbar ist.
+ */
+function image(value) {
+ const text = String(value ?? '').trim();
+ if (!text) return null;
+ const ok = /^data:image\/[a-z+]+;base64,/i.test(text) || /^https?:\/\//i.test(text);
+ if (!ok) return null;
+ return text.length > 400_000 ? null : text;
+}
+
+const report = [];
+function note(line) {
+ report.push(line);
+ console.log(line);
+}
+
+async function main() {
+ console.log(`ICON CRM Migration – Quelle ${BASE}`);
+ console.log(WRITE ? 'Modus: SCHREIBEN\n' : 'Modus: Probelauf (nichts wird geschrieben)\n');
+
+ await initDatabase();
+
+ const source = {};
+ for (const [key, table] of Object.entries(TABLES)) {
+ try {
+ source[key] = await fetchTable(table);
+ console.log(`gelesen: ${table} – ${source[key].length} Zeile(n)`);
+ } catch (err) {
+ source[key] = [];
+ console.warn(`übersprungen: ${table} – ${err.message}`);
+ }
+ }
+ console.log('');
+
+ /* ------------------------------------------------------------- Benutzer */
+
+ const userIds = new Map();
+ for (const row of source.users ?? []) {
+ const username = clean(row.username, 60);
+ if (!username) continue;
+
+ const [existing] = await pool.query('SELECT id FROM icon_users WHERE username = ? LIMIT 1', [username]);
+ if (existing[0]) {
+ userIds.set(row.id, existing[0].id);
+ continue;
+ }
+ if (!WRITE) {
+ note(`+ Benutzer ${username}`);
+ continue;
+ }
+
+ // Der alte Hash ist SHA-256. Er wird uebernommen; beim ersten Login
+ // stellt das Backend ihn auf bcrypt um (siehe routes/auth.js).
+ const [result] = await pool.query(
+ `INSERT INTO icon_users (username, email, name, role, password_hash, is_active)
+ VALUES (?, ?, ?, ?, ?, 1)`,
+ [
+ username,
+ clean(row.email, 190),
+ clean(row.name, 190) || username,
+ row.role === 'admin' ? 'admin' : 'user',
+ String(row.password_hash ?? ''),
+ ]
+ );
+ userIds.set(row.id, result.insertId);
+ note(`+ Benutzer ${username}`);
+ }
+
+ /* ---------------------------------------------------------- Unternehmen */
+
+ const companyIds = new Map(); // alte Kennung -> neue Kennung
+ const companyByName = new Map(); // Name (klein) -> neue Kennung
+
+ const [presentCompanies] = await pool.query('SELECT id, name FROM icon_companies');
+ for (const row of presentCompanies) companyByName.set(row.name.toLowerCase(), row.id);
+
+ for (const row of source.customers ?? []) {
+ const name = clean(row.company ?? row.name, 190);
+ if (!name) continue;
+
+ const known = companyByName.get(name.toLowerCase());
+ if (known) {
+ companyIds.set(row.id, known);
+ continue;
+ }
+ if (!WRITE) {
+ note(`+ Unternehmen ${name} (Kunde)`);
+ companyIds.set(row.id, -1);
+ continue;
+ }
+
+ const [result] = await pool.query(
+ `INSERT INTO icon_companies (name, website, address, logo, notes, is_customer, customer_state)
+ VALUES (?, ?, ?, ?, ?, 1, 'aktiv')`,
+ [name, clean(row.website, 300), clean(row.address, 2000), image(row.logo), clean(row.notes, 60000)]
+ );
+ companyIds.set(row.id, result.insertId);
+ companyByName.set(name.toLowerCase(), result.insertId);
+ note(`+ Unternehmen ${name} (Kunde)`);
+ }
+
+ /** Unternehmen zu einem Freitextnamen finden oder anlegen. */
+ async function companyForName(value) {
+ const name = clean(value, 190);
+ if (!name) return null;
+ const known = companyByName.get(name.toLowerCase());
+ if (known) return known;
+ if (!WRITE) {
+ note(`+ Unternehmen ${name} (aus Freitext)`);
+ return null;
+ }
+ const [result] = await pool.query('INSERT INTO icon_companies (name) VALUES (?)', [name]);
+ companyByName.set(name.toLowerCase(), result.insertId);
+ note(`+ Unternehmen ${name} (aus Freitext)`);
+ return result.insertId;
+ }
+
+ /* -------------------------------------------------------------- Kontakte */
+
+ const contactIds = new Map();
+ const contactByName = new Map();
+
+ const [presentContacts] = await pool.query(
+ "SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts"
+ );
+ for (const row of presentContacts) contactByName.set(String(row.name).toLowerCase(), row.id);
+
+ async function insertContact(row, kind, companyId) {
+ const { first_name, last_name } = splitName(row.name);
+ const full = [first_name, last_name].filter(Boolean).join(' ');
+
+ const known = contactByName.get(full.toLowerCase());
+ if (known) return known;
+ if (!WRITE) {
+ note(`+ Kontakt ${full} (${kind})`);
+ return null;
+ }
+
+ const [result] = await pool.query(
+ `INSERT INTO icon_contacts
+ (first_name, last_name, position, email, phone, linkedin, avatar, company_id,
+ kind, status, bio, tags, rate_onsite, rate_remote, cv_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ first_name,
+ last_name,
+ clean(row.role, 190),
+ clean(row.email, 190),
+ clean(row.phone, 80),
+ clean(row.linkedin, 300),
+ image(row.avatar),
+ companyId,
+ kind,
+ STATUS_MAP[row.status] ?? 'Interessiert',
+ clean(row.bio, 60000),
+ Array.isArray(row.tags) ? row.tags.join(', ').slice(0, 1000) : clean(row.tags, 1000),
+ money(row.hourlyRateOnsite ?? row.hourly_rate_onsite),
+ money(row.hourlyRateRemote ?? row.hourly_rate_remote),
+ clean(row.cv_url, 500),
+ ]
+ );
+
+ // Der Werdegang beginnt mit dem aktuellen Arbeitgeber - erst dadurch kann
+ // ein spaeterer Wechsel den Zweig "Verlassen" fuellen.
+ if (companyId) {
+ await pool.query(
+ `INSERT INTO icon_employments (contact_id, company_id, position, is_current)
+ VALUES (?, ?, ?, 1)`,
+ [result.insertId, companyId, clean(row.role, 190)]
+ );
+ }
+
+ contactByName.set(full.toLowerCase(), result.insertId);
+ note(`+ Kontakt ${full} (${kind})`);
+ return result.insertId;
+ }
+
+ for (const row of source.experts ?? []) {
+ const companyId = await companyForName(row.company);
+ const id = await insertContact(row, 'Experte', companyId);
+ if (id) contactIds.set(row.id, id);
+ }
+
+ for (const row of source.contacts ?? []) {
+ const companyId = companyIds.get(row.customer_id) ?? null;
+ const id = await insertContact(row, 'Ansprechpartner', companyId && companyId > 0 ? companyId : null);
+ if (id) contactIds.set(row.id, id);
+ }
+
+ /* ------------------------------------------------------- Ausschreibungen */
+
+ const tenderIds = new Map();
+ for (const row of source.tenders ?? []) {
+ const title = clean(row.title, 300);
+ if (!title) continue;
+
+ const [existing] = await pool.query('SELECT id FROM icon_tenders WHERE title = ? LIMIT 1', [title]);
+ if (existing[0]) {
+ tenderIds.set(row.id, existing[0].id);
+ continue;
+ }
+ if (!WRITE) {
+ note(`+ Ausschreibung ${title}`);
+ continue;
+ }
+
+ const [result] = await pool.query(
+ `INSERT INTO icon_tenders (title, source, link, company_id, status, stage, online, details, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ title,
+ clean(row.source, 190),
+ clean(row.link, 600),
+ companyIds.get(row.customer_id) ?? null,
+ TENDER_STATUS.has(row.status) ? row.status : 'Aktiv',
+ STAGE_MAP[row.kanbanStatus ?? row.kanban_status] ?? 'Ausschreibung',
+ row.online === false ? 0 : 1,
+ clean(row.details, 60000),
+ clean(row.notes, 60000),
+ ]
+ );
+ tenderIds.set(row.id, result.insertId);
+ note(`+ Ausschreibung ${title}`);
+ }
+
+ for (const row of source.tenderExperts ?? []) {
+ const tenderId = tenderIds.get(row.tender_id);
+ const contactId = contactIds.get(row.expert_id);
+ if (!tenderId || !contactId) continue;
+ if (!WRITE) continue;
+ await pool.query(
+ 'INSERT IGNORE INTO icon_tender_contacts (tender_id, contact_id) VALUES (?, ?)',
+ [tenderId, contactId]
+ );
+ }
+
+ /* -------------------------------------------------------------- Projekte */
+
+ for (const row of source.projects ?? []) {
+ const title = clean(row.title, 300);
+ if (!title) continue;
+
+ const [existing] = await pool.query('SELECT id FROM icon_projects WHERE title = ? LIMIT 1', [title]);
+ if (existing[0]) continue;
+ if (!WRITE) {
+ note(`+ Projekt ${title}`);
+ continue;
+ }
+
+ // Kunde und Experte standen im alten System als Freitext im Projekt.
+ const companyId = await companyForName(row.customer);
+ const expert = clean(row.expert, 190);
+ const contactId = expert ? (contactByName.get(expert.toLowerCase()) ?? null) : null;
+
+ await pool.query(
+ `INSERT INTO icon_projects (title, company_id, contact_id, started_on, ended_on, status, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [
+ title,
+ companyId,
+ contactId,
+ isoDate(row.start),
+ isoDate(row.end),
+ PROJECT_STATUS.has(row.status) ? row.status : 'Aktiv',
+ clean(row.notes, 60000),
+ ]
+ );
+ note(`+ Projekt ${title}`);
+ }
+
+ /* ---------------------------------------------------------------- Bilanz */
+
+ console.log('');
+ if (WRITE) {
+ const [[counts]] = await pool.query(
+ `SELECT (SELECT COUNT(*) FROM icon_companies) AS unternehmen,
+ (SELECT COUNT(*) FROM icon_contacts) AS kontakte,
+ (SELECT COUNT(*) FROM icon_tenders) AS ausschreibungen,
+ (SELECT COUNT(*) FROM icon_projects) AS projekte,
+ (SELECT COUNT(*) FROM icon_users) AS benutzer`
+ );
+ console.log('Bestand nach der Migration:', counts);
+ } else {
+ console.log(`Probelauf beendet – ${report.length} Datensatz/Datensaetze wuerden angelegt.`);
+ console.log('Zum Schreiben denselben Aufruf mit --write wiederholen.');
+ }
+
+ await pool.end();
+}
+
+main().catch((err) => {
+ console.error('Migration fehlgeschlagen:', err);
+ process.exit(1);
+});
diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js
new file mode 100644
index 0000000..f459d52
--- /dev/null
+++ b/server/src/routes/ai.js
@@ -0,0 +1,196 @@
+import express from 'express';
+import { requireAuth } from '../middleware/auth.js';
+import {
+ askModel,
+ aiConfigured,
+ aiModel,
+ aiStatus,
+ saveAiKey,
+ saveAiModel,
+} from '../ai.js';
+import { requireAdmin } from '../middleware/auth.js';
+import { logHistory } from '../history.js';
+
+const router = express.Router();
+router.use(requireAuth);
+
+/*
+ * Schreibhilfe fuer die Ausschreibungstexte.
+ *
+ * Der Editor schickt den aktuellen Text und eine Aufgabe; zurueck kommt HTML in
+ * genau dem Umfang, den der Editor auch darstellen kann.
+ *
+ * Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
+ * OpenAI.
+ */
+
+const TASKS = {
+ write: {
+ label: 'Text erzeugen',
+ instruction:
+ 'Schreibe eine vollständige, sachliche Projektausschreibung auf Deutsch. Gliedere sie in ' +
+ 'Überschriften: Rolle, Aufgaben, Anforderungen, Rahmenbedingungen.',
+ },
+ improve: {
+ label: 'Verbessern',
+ instruction:
+ 'Überarbeite den Text: klarer, sachlicher, ohne Floskeln und Wiederholungen. Inhalt und ' +
+ 'Aussage dürfen sich nicht ändern, es darf nichts hinzukommen, was nicht dasteht.',
+ },
+ shorten: {
+ label: 'Kürzen',
+ instruction:
+ 'Kürze den Text auf etwa die Hälfte, ohne eine inhaltliche Angabe zu verlieren. ' +
+ 'Anforderungen und Rahmenbedingungen bleiben vollständig.',
+ },
+ bullets: {
+ label: 'Als Stichpunkte',
+ instruction:
+ 'Forme den Text zu übersichtlichen Stichpunktlisten um, gruppiert nach Aufgaben und ' +
+ 'Anforderungen. Keine Angabe weglassen.',
+ },
+ profile: {
+ label: 'Anforderungsprofil',
+ instruction:
+ 'Leite aus dem Text ein knappes Anforderungsprofil ab: welche Fähigkeiten, welche Jahre ' +
+ 'Erfahrung, welche Branchenkenntnis. Nur, was im Text belegt ist.',
+ },
+};
+
+const SYSTEM = `Du bist Texter einer Personalvermittlung für IT- und Management-Beratung.
+Du antwortest ausschließlich mit dem fertigen Text als einfaches HTML.
+Erlaubt sind nur:
-
.
+Kein , kein , keine Code-Zäune, keine Erklärung, keine Anrede an den Nutzer.
+Sprache: Deutsch, sachlich, keine Werbefloskeln. Nichts erfinden, was nicht in der Vorlage steht.`;
+
+// GET /api/ai/status - die Oberflaeche blendet die Knoepfe ohne Schluessel aus.
+router.get('/status', (req, res) => {
+ res.json({ configured: aiConfigured(), model: aiModel(), tasks: Object.keys(TASKS) });
+});
+
+// POST /api/ai/text { task, current?, prompt? }
+router.post('/text', async (req, res, next) => {
+ const task = String(req.body?.task || 'write');
+ if (!TASKS[task]) return res.status(400).json({ error: 'Unbekannte Aufgabe.' });
+
+ const current = String(req.body?.current || '').slice(0, 40_000);
+ const prompt = String(req.body?.prompt || '').slice(0, 4_000);
+
+ if (task !== 'write' && !current.trim()) {
+ return res.status(400).json({ error: 'Es gibt noch keinen Text zum Überarbeiten.' });
+ }
+ if (task === 'write' && !prompt.trim() && !current.trim()) {
+ return res.status(400).json({ error: 'Bitte kurz beschreiben, worum es geht.' });
+ }
+
+ try {
+ const text = await askModel({
+ system: SYSTEM,
+ user: [
+ TASKS[task].instruction,
+ prompt.trim() ? `\nVorgabe des Nutzers:\n${prompt.trim()}` : '',
+ current.trim() ? `\nBisheriger Text:\n${current.trim()}` : '',
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ maxTokens: 6000,
+ });
+
+ res.json({ text: stripFences(text), model: aiModel() });
+ } catch (err) {
+ if (err?.status) return res.status(err.status).json({ error: err.message });
+ next(err);
+ }
+});
+
+/* -------------------------------------------------------------------------
+ * Schluessel und Modell verwalten (Einstellungen -> KI, nur Administratoren)
+ *
+ * Der Schluessel geht nie zum Browser zurueck - weder hier noch sonst irgendwo.
+ * Gelesen wird er ausschliesslich verkuerzt; geschrieben wird er einmal und
+ * liegt danach in der Datenbank.
+ * ---------------------------------------------------------------------- */
+
+// GET /api/ai/config
+router.get('/config', requireAdmin, (req, res) => {
+ res.json(aiStatus());
+});
+
+// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder.
+router.patch('/config', requireAdmin, async (req, res, next) => {
+ const body = req.body || {};
+ const hasKey = Object.prototype.hasOwnProperty.call(body, 'key');
+ const hasModel = Object.prototype.hasOwnProperty.call(body, 'model');
+ if (!hasKey && !hasModel) {
+ return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
+ }
+
+ const key = hasKey ? String(body.key ?? '').trim() : null;
+ if (hasKey && key && !/^sk-[A-Za-z0-9_-]{20,}$/.test(key)) {
+ // Nur die Form pruefen, nicht die Gueltigkeit - die zeigt der Test.
+ return res.status(400).json({ error: 'Das sieht nicht nach einem OpenAI-Schlüssel aus (beginnt mit "sk-").' });
+ }
+
+ try {
+ if (hasKey) await saveAiKey(key);
+ if (hasModel) await saveAiModel(body.model);
+
+ /*
+ * In die Historie kommt, DASS jemand den Schluessel gewechselt hat - nie
+ * der Wert. Ein stiller Wechsel waere spaeter nicht mehr nachvollziehbar.
+ */
+ await logHistory({
+ entityType: 'system',
+ entityId: null,
+ entityLabel: 'KI',
+ action: 'updated',
+ text: [
+ hasKey ? (key ? 'OpenAI-Schlüssel hinterlegt' : 'OpenAI-Schlüssel entfernt') : null,
+ hasModel ? `Modell: ${aiModel()}` : null,
+ ]
+ .filter(Boolean)
+ .join(', '),
+ userId: req.user.id,
+ });
+
+ res.json(aiStatus());
+ } catch (err) {
+ next(err);
+ }
+});
+
+/*
+ * POST /api/ai/test - ein winziger Aufruf gegen das Modell.
+ *
+ * Ohne ihn merkt man einen falschen Schluessel erst, wenn jemand mitten in der
+ * Arbeit einen Lebenslauf auslesen will.
+ */
+router.post('/test', requireAdmin, async (req, res) => {
+ if (!aiConfigured()) {
+ return res.status(503).json({ error: 'Es ist kein Schlüssel hinterlegt.' });
+ }
+ const started = Date.now();
+ try {
+ const text = await askModel({
+ system: 'Antworte mit genau einem Wort.',
+ user: 'Sag OK.',
+ maxTokens: 300,
+ });
+ res.json({ ok: true, model: aiModel(), ms: Date.now() - started, answer: text.trim().slice(0, 40) });
+ } catch (err) {
+ res.status(err?.status ?? 502).json({ error: err.message });
+ }
+});
+
+/*
+ * Modelle setzen die Antwort gern in ```html … ``` - das waere im Editor
+ * sichtbarer Muell.
+ */
+function stripFences(text) {
+ return String(text ?? '')
+ .replace(/^\s*```(?:html)?\s*/i, '')
+ .replace(/\s*```\s*$/i, '')
+ .trim();
+}
+
+export default router;
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
new file mode 100644
index 0000000..de5cb4a
--- /dev/null
+++ b/server/src/routes/auth.js
@@ -0,0 +1,169 @@
+import express from 'express';
+import { createHash } from 'node:crypto';
+import bcrypt from 'bcryptjs';
+import { pool } from '../db.js';
+import { signToken, requireAuth } from '../middleware/auth.js';
+import { normalizeAvatar, normalizeColor, normalizeTheme, normalizeThemeMode } from '../userFields.js';
+
+const router = express.Router();
+
+export function publicUser(row) {
+ /*
+ * Ein Design, das es nicht mehr gibt, wird als "nicht gesetzt" ausgeliefert.
+ *
+ * Die Prüfung beim Schreiben reicht dafür nicht: sie greift erst ab dem
+ * Zeitpunkt, zu dem ein Wert aus der Liste fliegt. Wer ihn vorher gewählt
+ * hatte, trägt ihn weiter in der Datenbank - und die Oberfläche bekäme einen
+ * Namen, zu dem sie keine Beschriftung kennt.
+ */
+ const theme = normalizeTheme(row.theme ?? null);
+
+ return {
+ id: row.id,
+ username: row.username,
+ email: row.email ?? null,
+ name: row.name,
+ role: row.role,
+ color: row.color ?? null,
+ avatar: row.avatar ?? null,
+ theme: theme.error ? null : theme.value,
+ theme_mode: row.theme_mode ?? null,
+ is_active: Boolean(row.is_active),
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+}
+
+// POST /api/auth/login { identifier, password }
+router.post('/login', async (req, res, next) => {
+ const identifier = String(req.body?.identifier || '').trim();
+ const password = String(req.body?.password || '');
+
+ if (!identifier || !password) {
+ return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' });
+ }
+
+ try {
+ const [rows] = await pool.query(
+ 'SELECT * FROM icon_users WHERE username = ? OR email = ? LIMIT 1',
+ [identifier, identifier.toLowerCase()]
+ );
+ const user = rows[0];
+
+ // Bewusst dieselbe Meldung fuer "kein Benutzer" und "falsches Passwort",
+ // damit sich ueber die API keine Benutzernamen aufzaehlen lassen.
+ const invalid = () => res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
+ if (!user) return invalid();
+
+ const ok = await verifyPassword(password, user);
+ if (!ok) return invalid();
+ if (!user.is_active) {
+ return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
+ }
+
+ res.json({ token: signToken(user), user: publicUser(user) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/*
+ * Passwortpruefung mit Uebergang aus dem alten CRM.
+ *
+ * Dort lagen die Passwoerter als ungesalzenes SHA-256 in Supabase. Solche
+ * Hashes sind an ihrer Form erkennbar (64 Hexziffern) - ein bcrypt-Hash
+ * beginnt immer mit $2. Stimmt ein Altpasswort, wird es im selben Zug auf
+ * bcrypt umgeschrieben: beim naechsten Mal gilt nur noch der starke Hash.
+ *
+ * Das ist bewusst kein dauerhafter zweiter Weg, sondern eine Einbahnstrasse -
+ * neu vergeben wird ausschliesslich bcrypt.
+ */
+async function verifyPassword(password, user) {
+ const hash = String(user.password_hash ?? '');
+
+ if (/^[a-f0-9]{64}$/i.test(hash)) {
+ const legacy = createHash('sha256').update(password, 'utf8').digest('hex');
+ if (legacy.toLowerCase() !== hash.toLowerCase()) return false;
+
+ await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [
+ await bcrypt.hash(password, 12),
+ user.id,
+ ]);
+ console.log(`[ICON CRM] Passwort von "${user.username}" auf bcrypt umgestellt.`);
+ return true;
+ }
+
+ return bcrypt.compare(password, hash);
+}
+
+// GET /api/auth/me
+router.get('/me', requireAuth, (req, res) => {
+ res.json({ user: publicUser(req.user) });
+});
+
+// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? }
+// Jeder darf seine eigene Farbe, sein Foto und seine Darstellung setzen.
+// null bzw. "" setzt das Feld zurueck.
+router.patch('/me', requireAuth, async (req, res, next) => {
+ const fields = [];
+ const values = [];
+ const changed = {};
+
+ for (const [field, normalize] of [
+ ['color', normalizeColor],
+ ['avatar', normalizeAvatar],
+ ['theme', normalizeTheme],
+ ['theme_mode', normalizeThemeMode],
+ ]) {
+ if (req.body?.[field] === undefined) continue;
+ const result = normalize(req.body[field]);
+ if (result.error) return res.status(400).json({ error: result.error });
+ fields.push(`${field} = ?`);
+ values.push(result.value);
+ changed[field] = result.value;
+ }
+
+ if (fields.length === 0) {
+ return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
+ }
+
+ try {
+ await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, req.user.id]);
+ res.json({ user: publicUser({ ...req.user, ...changed }) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+// POST /api/auth/change-password { current_password, new_password }
+router.post('/change-password', requireAuth, async (req, res, next) => {
+ const current = String(req.body?.current_password || '');
+ const next_ = String(req.body?.new_password || '');
+
+ if (!current || !next_) {
+ return res.status(400).json({ error: 'Aktuelles und neues Passwort sind erforderlich.' });
+ }
+ if (next_.length < 8) {
+ return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' });
+ }
+
+ try {
+ const [rows] = await pool.query('SELECT password_hash FROM icon_users WHERE id = ? LIMIT 1', [
+ req.user.id,
+ ]);
+ if (!rows[0]) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
+
+ const ok = await bcrypt.compare(current, rows[0].password_hash);
+ if (!ok) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
+
+ await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [
+ await bcrypt.hash(next_, 12),
+ req.user.id,
+ ]);
+ res.json({ ok: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+export default router;
diff --git a/server/src/routes/companies.js b/server/src/routes/companies.js
new file mode 100644
index 0000000..0be7754
--- /dev/null
+++ b/server/src/routes/companies.js
@@ -0,0 +1,379 @@
+import express from 'express';
+import { pool } from '../db.js';
+import { requireAuth } from '../middleware/auth.js';
+import { logHistory, diffFields, describeChanges, indexMentions } from '../history.js';
+
+const router = express.Router();
+router.use(requireAuth);
+
+/*
+ * Unternehmen - die fruehere Ansicht "Kunden".
+ *
+ * Ein Kunde ist hier kein eigener Datentyp mehr, sondern ein Unternehmen mit
+ * is_customer = 1. Der Umschalter in der Oberflaeche schickt deshalb nur den
+ * Parameter `scope` und bekommt dieselbe Struktur zurueck.
+ */
+
+const EDITABLE = [
+ 'name',
+ 'group_name',
+ 'industry',
+ 'employees',
+ 'website',
+ 'address',
+ 'logo',
+ 'rating',
+ 'is_customer',
+ 'customer_state',
+ 'freelancers',
+ 'notes',
+ 'owner_id',
+ 'primary_contact_id',
+];
+
+function readBody(body) {
+ const out = {};
+ if (body.name !== undefined) out.name = String(body.name).trim().slice(0, 190);
+ if (body.group_name !== undefined) out.group_name = emptyToNull(body.group_name, 190);
+ if (body.industry !== undefined) out.industry = emptyToNull(body.industry, 190);
+ if (body.employees !== undefined) {
+ const n = Number(body.employees);
+ out.employees = Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
+ }
+ if (body.website !== undefined) out.website = emptyToNull(body.website, 300);
+ if (body.address !== undefined) out.address = emptyToNull(body.address, 2000);
+ if (body.logo !== undefined) out.logo = body.logo ? String(body.logo) : null;
+ if (body.rating !== undefined) {
+ const n = Number(body.rating);
+ out.rating = Number.isFinite(n) ? Math.min(5, Math.max(0, Math.round(n))) : 0;
+ }
+ if (body.is_customer !== undefined) out.is_customer = body.is_customer ? 1 : 0;
+ if (body.customer_state !== undefined) {
+ out.customer_state = body.customer_state === 'alumni' ? 'alumni' : 'aktiv';
+ }
+ if (body.freelancers !== undefined) {
+ out.freelancers = ['ja', 'nein'].includes(body.freelancers) ? body.freelancers : 'unbekannt';
+ }
+ if (body.notes !== undefined) out.notes = body.notes ? String(body.notes) : null;
+ if (body.owner_id !== undefined) {
+ const n = Number(body.owner_id);
+ out.owner_id = Number.isInteger(n) && n > 0 ? n : null;
+ }
+ if (body.primary_contact_id !== undefined) {
+ const n = Number(body.primary_contact_id);
+ out.primary_contact_id = Number.isInteger(n) && n > 0 ? n : null;
+ }
+ return out;
+}
+
+function emptyToNull(value, max) {
+ const text = String(value ?? '').trim();
+ return text ? text.slice(0, max) : null;
+}
+
+// GET /api/companies?scope=all|customers|alumni&q=&industry=&group=&rating_min=
+router.get('/', async (req, res, next) => {
+ const where = [];
+ const params = [];
+
+ const scope = String(req.query.scope || 'all');
+ if (scope === 'customers') where.push("c.is_customer = 1 AND c.customer_state = 'aktiv'");
+ else if (scope === 'alumni') where.push("c.is_customer = 1 AND c.customer_state = 'alumni'");
+ else if (scope === 'kunden') where.push('c.is_customer = 1');
+
+ const q = String(req.query.q || '').trim();
+ if (q) {
+ where.push('(c.name LIKE ? OR c.group_name LIKE ? OR c.industry LIKE ? OR c.address LIKE ?)');
+ params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
+ }
+ if (req.query.industry) {
+ where.push('c.industry = ?');
+ params.push(String(req.query.industry));
+ }
+ if (req.query.group) {
+ where.push('c.group_name = ?');
+ params.push(String(req.query.group));
+ }
+ if (req.query.rating_min) {
+ where.push('c.rating >= ?');
+ params.push(Number(req.query.rating_min) || 0);
+ }
+ if (req.query.freelancers) {
+ where.push('c.freelancers = ?');
+ params.push(String(req.query.freelancers));
+ }
+
+ try {
+ const [rows] = await pool.query(
+ `SELECT c.*,
+ (SELECT COUNT(*) FROM icon_contacts k
+ WHERE k.company_id = c.id) AS contact_count,
+ (SELECT COUNT(*) FROM icon_employments e
+ WHERE e.company_id = c.id AND e.is_current = 0) AS former_count
+ FROM icon_companies c
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+ ORDER BY c.name`,
+ params
+ );
+ res.json({ companies: rows.map(normalizeRow) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+/** Auswahllisten fuer Branche und Gruppe - gespeist aus dem Bestand. */
+router.get('/options', async (req, res, next) => {
+ try {
+ const [industries] = await pool.query(
+ "SELECT DISTINCT industry FROM icon_companies WHERE industry IS NOT NULL AND industry <> '' ORDER BY industry"
+ );
+ const [groups] = await pool.query(
+ "SELECT DISTINCT group_name FROM icon_companies WHERE group_name IS NOT NULL AND group_name <> '' ORDER BY group_name"
+ );
+ res.json({
+ industries: industries.map((r) => r.industry),
+ groups: groups.map((r) => r.group_name),
+ });
+ } catch (err) {
+ next(err);
+ }
+});
+
+// GET /api/companies/:id - Karte inklusive Stammbaum, Interviews und Historie
+router.get('/:id', async (req, res, next) => {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
+
+ try {
+ const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ? LIMIT 1', [id]);
+ if (!rows[0]) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' });
+
+ // Aktuelle Belegschaft, soweit sie uns bekannt ist.
+ const [current] = await pool.query(
+ `SELECT id, first_name, last_name, position, email, phone, avatar, kind, status
+ FROM icon_contacts WHERE company_id = ? ORDER BY last_name, first_name`,
+ [id]
+ );
+
+ /*
+ * Der Zweig "Verlassen" des Stammbaums: alle, die hier einmal gearbeitet
+ * haben und es jetzt nicht mehr tun. Ausgeschlossen wird, wer laut
+ * icon_contacts aktuell wieder hier ist - sonst stuende derselbe Mensch in
+ * beiden Zweigen.
+ */
+ const [former] = await pool.query(
+ `SELECT e.id AS employment_id, e.position, e.started_on, e.ended_on,
+ k.id, k.first_name, k.last_name, k.avatar, k.kind, k.status,
+ k.company_id AS now_company_id, n.name AS now_company
+ FROM icon_employments e
+ JOIN icon_contacts k ON k.id = e.contact_id
+ LEFT JOIN icon_companies n ON n.id = k.company_id
+ WHERE e.company_id = ? AND e.is_current = 0
+ AND (k.company_id IS NULL OR k.company_id <> ?)
+ ORDER BY e.ended_on DESC, k.last_name`,
+ [id, id]
+ );
+
+ const [interviews] = await pool.query(
+ `SELECT i.*, u.name AS user_name,
+ CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
+ FROM icon_interviews i
+ LEFT JOIN icon_users u ON u.id = i.user_id
+ LEFT JOIN icon_contacts k ON k.id = i.contact_id
+ WHERE i.company_id = ? ORDER BY i.held_on DESC`,
+ [id]
+ );
+
+ const [history] = await pool.query(
+ `SELECT h.*, u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar
+ FROM icon_history h
+ LEFT JOIN icon_users u ON u.id = h.user_id
+ WHERE h.entity_type = 'company' AND h.entity_id = ?
+ ORDER BY h.created_at DESC, h.id DESC LIMIT 500`,
+ [id]
+ );
+
+ // Wo wurde dieses Unternehmen ueberall per @ genannt?
+ const [mentioned] = await pool.query(
+ `SELECT h.*, u.name AS user_name, u.color AS user_color
+ FROM icon_mentions m
+ JOIN icon_history h ON h.id = m.source_id AND m.source_type = 'history'
+ LEFT JOIN icon_users u ON u.id = h.user_id
+ WHERE m.target_type = 'company' AND m.target_id = ?
+ ORDER BY h.created_at DESC LIMIT 200`,
+ [id]
+ );
+
+ res.json({
+ company: normalizeRow(rows[0]),
+ contacts: current,
+ former,
+ interviews,
+ history: history.map(normalizeHistory),
+ mentioned: mentioned.map(normalizeHistory),
+ });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.post('/', async (req, res, next) => {
+ const data = readBody(req.body || {});
+ if (!data.name) return res.status(400).json({ error: 'Der Name des Unternehmens ist erforderlich.' });
+
+ try {
+ const columns = Object.keys(data);
+ const [result] = await pool.query(
+ `INSERT INTO icon_companies (${columns.join(', ')}, created_by)
+ VALUES (${columns.map(() => '?').join(', ')}, ?)`,
+ [...columns.map((c) => data[c]), req.user.id]
+ );
+
+ await logHistory({
+ entityType: 'company',
+ entityId: result.insertId,
+ entityLabel: data.name,
+ action: 'created',
+ text: `${data.name} neu angelegt`,
+ userId: req.user.id,
+ });
+ if (data.notes) await indexMentions('company', result.insertId, data.notes);
+
+ const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ?', [result.insertId]);
+ res.status(201).json({ company: normalizeRow(rows[0]) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.patch('/:id', async (req, res, next) => {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
+
+ try {
+ const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ? LIMIT 1', [id]);
+ const before = rows[0];
+ if (!before) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' });
+
+ const data = readBody(req.body || {});
+ if (data.name !== undefined && !data.name) {
+ return res.status(400).json({ error: 'Der Name des Unternehmens ist erforderlich.' });
+ }
+ const columns = Object.keys(data);
+ if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
+
+ await pool.query(
+ `UPDATE icon_companies SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
+ [...columns.map((c) => data[c]), id]
+ );
+
+ // Das Logo interessiert in der Historie niemanden als Data-URL - der
+ // Vergleich wuerde nur Hunderte Kilobyte in die Tabelle schreiben.
+ const changes = diffFields(before, data, EDITABLE.filter((f) => f !== 'logo'));
+ if (data.logo !== undefined && (before.logo ?? null) !== (data.logo ?? null)) {
+ changes.logo = ['', data.logo ? 'gesetzt' : 'entfernt'];
+ }
+
+ if (Object.keys(changes).length > 0) {
+ await logHistory({
+ entityType: 'company',
+ entityId: id,
+ entityLabel: data.name ?? before.name,
+ action: 'updated',
+ text: `${data.name ?? before.name}: ${describeChanges(changes)}`,
+ changes,
+ userId: req.user.id,
+ });
+ }
+ if (data.notes !== undefined) await indexMentions('company', id, data.notes);
+
+ const [updated] = await pool.query('SELECT * FROM icon_companies WHERE id = ?', [id]);
+ res.json({ company: normalizeRow(updated[0]) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.delete('/:id', async (req, res, next) => {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
+
+ try {
+ const [rows] = await pool.query('SELECT name FROM icon_companies WHERE id = ? LIMIT 1', [id]);
+ if (!rows[0]) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' });
+
+ await pool.query('DELETE FROM icon_companies WHERE id = ?', [id]);
+ await logHistory({
+ entityType: 'company',
+ entityId: null,
+ entityLabel: rows[0].name,
+ action: 'deleted',
+ text: `${rows[0].name} gelöscht`,
+ userId: req.user.id,
+ });
+ res.json({ ok: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+// POST /api/companies/:id/interviews
+router.post('/:id/interviews', async (req, res, next) => {
+ const id = Number(req.params.id);
+ const heldOn = String(req.body?.held_on || '').slice(0, 10);
+ if (!Number.isInteger(id) || !/^\d{4}-\d{2}-\d{2}$/.test(heldOn)) {
+ return res.status(400).json({ error: 'Unternehmen und Datum sind erforderlich.' });
+ }
+
+ try {
+ const contactId = Number(req.body?.contact_id) || null;
+ const userId = Number(req.body?.user_id) || req.user.id;
+ const notes = req.body?.notes ? String(req.body.notes) : null;
+
+ const [result] = await pool.query(
+ `INSERT INTO icon_interviews (contact_id, company_id, user_id, held_on, notes, created_by)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [contactId, id, userId, heldOn, notes, req.user.id]
+ );
+
+ const [[company]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [id]);
+ await logHistory({
+ entityType: 'company',
+ entityId: id,
+ entityLabel: company?.name,
+ action: 'interview',
+ text: `Interview am ${heldOn}${notes ? ` – ${notes}` : ''}`,
+ userId: req.user.id,
+ });
+
+ res.status(201).json({ id: result.insertId });
+ } catch (err) {
+ next(err);
+ }
+});
+
+function normalizeRow(row) {
+ return {
+ ...row,
+ is_customer: Boolean(row.is_customer),
+ rating: Number(row.rating ?? 0),
+ employees: row.employees === null ? null : Number(row.employees),
+ contact_count: row.contact_count === undefined ? undefined : Number(row.contact_count),
+ former_count: row.former_count === undefined ? undefined : Number(row.former_count),
+ };
+}
+
+function normalizeHistory(row) {
+ return { ...row, changes: row.changes ? safeParse(row.changes) : null };
+}
+
+function safeParse(value) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+}
+
+export default router;
+export { normalizeHistory };
diff --git a/server/src/routes/connections.js b/server/src/routes/connections.js
new file mode 100644
index 0000000..8f3dcc6
--- /dev/null
+++ b/server/src/routes/connections.js
@@ -0,0 +1,102 @@
+import express from 'express';
+import { pool } from '../db.js';
+import { requireAuth } from '../middleware/auth.js';
+import { logHistory } from '../history.js';
+
+const router = express.Router();
+router.use(requireAuth);
+
+/*
+ * Verbindungen zwischen Kontakten ("wer kennt wen").
+ *
+ * Das Paar wird immer aufsteigend gespeichert - damit greift der Unique-Key
+ * in beide Richtungen und A-B laesst sich nicht zusaetzlich als B-A anlegen.
+ */
+
+router.get('/', async (req, res, next) => {
+ try {
+ const [rows] = await pool.query(
+ `SELECT c.*,
+ CONCAT_WS(' ', a.first_name, a.last_name) AS a_name,
+ CONCAT_WS(' ', b.first_name, b.last_name) AS b_name
+ FROM icon_connections c
+ JOIN icon_contacts a ON a.id = c.contact_a
+ JOIN icon_contacts b ON b.id = c.contact_b
+ ORDER BY c.id DESC`
+ );
+ res.json({ connections: rows });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.post('/', async (req, res, next) => {
+ const a = Number(req.body?.contact_a);
+ const b = Number(req.body?.contact_b);
+ if (!Number.isInteger(a) || !Number.isInteger(b) || a < 1 || b < 1) {
+ return res.status(400).json({ error: 'Zwei Kontakte sind erforderlich.' });
+ }
+ if (a === b) return res.status(400).json({ error: 'Ein Kontakt kann nicht mit sich selbst verbunden werden.' });
+
+ const [low, high] = a < b ? [a, b] : [b, a];
+ const kind = String(req.body?.kind || 'Kontakt').trim().slice(0, 60) || 'Kontakt';
+
+ try {
+ const [result] = await pool.query(
+ `INSERT INTO icon_connections (contact_a, contact_b, kind, created_by)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE kind = VALUES(kind)`,
+ [low, high, kind, req.user.id]
+ );
+
+ const [names] = await pool.query(
+ "SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
+ [low, high]
+ );
+ const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
+
+ for (const id of [low, high]) {
+ await logHistory({
+ entityType: 'contact',
+ entityId: id,
+ entityLabel: nameOf(id),
+ action: 'connection',
+ text: `Verbindung zu ${nameOf(id === low ? high : low)} angelegt (${kind})`,
+ userId: req.user.id,
+ });
+ }
+
+ res.status(201).json({ id: result.insertId || null, contact_a: low, contact_b: high, kind });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.patch('/:id', async (req, res, next) => {
+ const id = Number(req.params.id);
+ const kind = String(req.body?.kind || '').trim().slice(0, 60);
+ if (!Number.isInteger(id) || !kind) return res.status(400).json({ error: 'Art der Verbindung fehlt.' });
+
+ try {
+ const [result] = await pool.query('UPDATE icon_connections SET kind = ? WHERE id = ?', [kind, id]);
+ if (result.affectedRows === 0) return res.status(404).json({ error: 'Verbindung nicht gefunden.' });
+ res.json({ ok: true, kind });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.delete('/:id', async (req, res, next) => {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
+
+ try {
+ const [result] = await pool.query('DELETE FROM icon_connections WHERE id = ?', [id]);
+ if (result.affectedRows === 0) return res.status(404).json({ error: 'Verbindung nicht gefunden.' });
+ res.json({ ok: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
+export default router;