Compare commits

..

No commits in common. "4715e1b482e71af3ba3877f7e657ec305fcb3aa2" and "b2853a952beaf478b3d8fb7e7f8e034cf069da43" have entirely different histories.

60 changed files with 4719 additions and 8039 deletions

6
.gitignore vendored
View File

@ -36,9 +36,3 @@ uploads/*
/phases.md /phases.md
/architecture.md /architecture.md
/Guide.md /Guide.md
# Dedicated Documentation Folder
/documents/
/minio-seed/
ai-advisor.md
testing-strategy.md

View File

@ -0,0 +1,95 @@
const fs = require('fs');
async function runTests() {
const BASE_URL = 'http://localhost:5001/api/v1';
let token = '';
try {
console.log('1. Testing Health Endpoint...');
const health = await fetch(`${BASE_URL}/health`);
const healthData = await health.json();
console.log('Health:', healthData);
if (!health.ok) throw new Error('Health check failed');
console.log('\n2. Testing Registration...');
const regRes = await fetch(`${BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@tech4biz.com', password: 'securepassword', role: 'ADMIN' })
});
console.log('Registration Status (Admin):', regRes.status);
if (!regRes.ok && regRes.status !== 400) throw new Error('Registration failed');
const regPartnerRes = await fetch(`${BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'partner@tech4biz.com', password: 'securepassword', role: 'PARTNER_USER' })
});
console.log('Registration Status (Partner):', regPartnerRes.status);
if (!regPartnerRes.ok && regPartnerRes.status !== 400) throw new Error('Partner Registration failed');
console.log('\n3. Testing Login...');
const loginRes = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@tech4biz.com', password: 'securepassword' })
});
const loginData = await loginRes.json();
console.log('Login Status:', loginRes.status);
if (!loginRes.ok) throw new Error('Login failed');
token = loginData.accessToken;
console.log('Received Access Token: ', token.substring(0, 15) + '...');
console.log('\n4. Testing Organization Creation...');
const orgRes = await fetch(`${BASE_URL}/organizations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Tech4Biz Partners' })
});
const orgData = await orgRes.json();
console.log('Organization Created:', orgData);
if (!orgRes.ok) throw new Error('Org creation failed');
console.log('\n5. Testing Legal Document Creation...');
const legalRes = await fetch(`${BASE_URL}/legal/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'NDA', version: '1.0', content: 'You must not disclose anything.' })
});
const legalData = await legalRes.json();
console.log('Legal Document Created:', legalData);
if (!legalRes.ok) throw new Error('Legal creation failed');
console.log('\n6. Testing Asset Upload...');
// Create a dummy file
fs.writeFileSync('test-file.txt', 'This is a test file for upload.');
const formData = new FormData();
const fileBlob = new Blob([fs.readFileSync('test-file.txt')], { type: 'text/plain' });
formData.append('file', fileBlob, 'test-file.txt');
formData.append('title', 'My Secret Document');
const uploadRes = await fetch(`${BASE_URL}/assets/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const uploadData = await uploadRes.json();
console.log('Asset Uploaded:', uploadData);
if (!uploadRes.ok) throw new Error('Upload failed');
fs.unlinkSync('test-file.txt');
console.log('\n✅ ALL TESTS PASSED SUCCESSFULLY!');
} catch (error) {
console.error('\n❌ TEST FAILED:', error);
}
}
runTests();

View File

@ -12,7 +12,6 @@
"@aws-sdk/s3-request-presigner": "^3.1083.0", "@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -21,12 +20,9 @@
"express": "^4.19.2", "express": "^4.19.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0", "pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -38,7 +34,6 @@
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",
@ -462,190 +457,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
@ -1257,16 +1068,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@ -1336,15 +1137,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -1384,50 +1176,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agent-base/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/agent-base/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==",
"license": "MIT"
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@ -1472,27 +1220,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@ -1503,18 +1236,6 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1525,26 +1246,6 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt": { "node_modules/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -1579,12 +1280,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -1780,19 +1475,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chart.js": { "node_modules/chart.js": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@ -1873,27 +1555,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@ -1971,12 +1632,6 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@ -1994,18 +1649,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -2090,15 +1733,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -2145,12 +1779,6 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -2218,15 +1846,6 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2366,21 +1985,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -2527,26 +2131,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -2564,22 +2148,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2589,15 +2157,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -2763,21 +2322,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -2867,42 +2411,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/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==",
"license": "MIT"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -2922,12 +2430,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2996,12 +2498,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3054,48 +2550,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@ -3117,15 +2571,6 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -3175,17 +2620,6 @@
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru.min": { "node_modules/lru.min": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
@ -3209,30 +2643,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3548,18 +2958,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -3618,15 +3016,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -3650,38 +3039,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@ -3899,12 +3256,6 @@
} }
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proper-lockfile": { "node_modules/proper-lockfile": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@ -3937,15 +3288,6 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@ -4200,12 +3542,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -4342,12 +3678,6 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sqlstring": { "node_modules/sqlstring": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@ -4358,18 +3688,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -4538,12 +3856,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici": { "node_modules/undici": {
"version": "7.28.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@ -4664,54 +3976,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -13,7 +13,6 @@
"@aws-sdk/s3-request-presigner": "^3.1083.0", "@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -22,12 +21,9 @@
"express": "^4.19.2", "express": "^4.19.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0", "pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -39,7 +35,6 @@
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",

View File

@ -24,19 +24,6 @@ model Organization {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
sharedAssets SharedAsset[] sharedAssets SharedAsset[]
branches Branch[]
}
model Branch {
id String @id @default(uuid())
name String
code String? @unique
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
users User[]
sharedAssets SharedAsset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model User { model User {
@ -49,7 +36,6 @@ model User {
inviteToken String? @unique inviteToken String? @unique
inviteTokenExp DateTime? inviteTokenExp DateTime?
organizationId String? organizationId String?
branchId String?
onboardingStatus String @default("PENDING_ONBOARDING") onboardingStatus String @default("PENDING_ONBOARDING")
partnerGroup String? partnerGroup String?
assignedNdaId String? assignedNdaId String?
@ -63,14 +49,12 @@ model User {
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull) assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull) assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id]) organization Organization? @relation(fields: [organizationId], references: [id])
branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
acceptances LegalAcceptance[] acceptances LegalAcceptance[]
auditLogs AuditLog[] auditLogs AuditLog[]
sharedAssets SharedAsset[] sharedAssets SharedAsset[]
downloadRequests DownloadRequest[] downloadRequests DownloadRequest[]
chatSessions ChatSession[]
} }
model Asset { model Asset {
@ -92,101 +76,24 @@ model Asset {
thumbnailUrl String? thumbnailUrl String?
problemStatement String? @db.Text problemStatement String? @db.Text
solution String? @db.Text solution String? @db.Text
contentType String?
includeInKnowledgeBase Boolean @default(true)
knowledgeScope String? @default("CATALOG_SHARED")
folderId String? folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
verticals Vertical[] @relation("AssetVerticals")
techStacks TechStack[] @relation("AssetTechStacks")
engagementTypes EngagementType[] @relation("AssetEngagementTypes")
complianceStandards ComplianceStandard[] @relation("AssetComplianceStandards")
sharedWith SharedAsset[] sharedWith SharedAsset[]
downloadRequests DownloadRequest[] downloadRequests DownloadRequest[]
assetGroups AssetGroup[] assetGroups AssetGroup[]
embeddings AssetEmbedding[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Vertical {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetVerticals")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TechStack {
id String @id @default(uuid())
name String @unique
slug String @unique
category String // "Languages & Frameworks", "AI & ML", "Data & Backend", "Cloud & Infra"
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetTechStacks")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EngagementType {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetEngagementTypes")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ComplianceStandard {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetComplianceStandards")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetNotification {
id String @id @default(uuid())
title String
message String @db.Text
sentBy String
targetOrgIds String[]
assetIds String[]
createdAt DateTime @default(now())
}
model SharedAsset { model SharedAsset {
id String @id @default(uuid()) id String @id @default(uuid())
assetId String assetId String
organizationId String organizationId String
userId String? userId String?
branchId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
branch Branch? @relation(fields: [branchId], references: [id], onDelete: Cascade)
@@unique([assetId, organizationId, userId]) @@unique([assetId, organizationId, userId])
} }
@ -271,7 +178,6 @@ model EcosystemOffering {
logoUrl String? logoUrl String?
mediaUrl String? mediaUrl String?
mediaType String? // "IMAGE" | "VIDEO" | "GIF" mediaType String? // "IMAGE" | "VIDEO" | "GIF"
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0) orderIndex Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -286,42 +192,8 @@ model ContentShowcase {
thumbnailUrl String? thumbnailUrl String?
redirectUrl String? redirectUrl String?
redirectLabel String? @default("Learn More") redirectLabel String? @default("Learn More")
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0) orderIndex Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model AssetEmbedding {
id String @id @default(uuid())
assetId String
chunkIndex Int
chunkType String @default("TEXT") // TEXT, SLIDE, SHEET, PAGE, TRANSCRIPT
sourceMetadata Json? // OKF Standard JSON: { title, location: "Page 4" | "Slide 2" | "Sheet Data", okfCategory }
content String @db.Text
vector String // Vector array string representation for similarity search
createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@index([assetId])
}
model ChatSession {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages ChatMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ChatMessage {
id String @id @default(uuid())
sessionId String
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sender String // "USER" | "ASSISTANT"
content String @db.Text
citations Json? // Array of OKF standardized source citations
createdAt DateTime @default(now())
}

View File

@ -1,148 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from '../src/utils/db';
const INITIAL_VERTICALS = [
{ name: 'Cybersecurity', slug: 'cybersecurity', icon: 'Shield', color: '#ef4444', description: 'OT, Infrastructure & Data Defense' },
{ name: 'AI & ML', slug: 'ai-ml', icon: 'Cpu', color: '#8b5cf6', description: 'Artificial Intelligence & Machine Learning' },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Activity', color: '#ec4899', description: 'Medical, Diagnostics & BioTech' },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'Landmark', color: '#10b981', description: 'FinTech, Payments & Risk Analytics' },
{ name: 'Insurance', slug: 'insurance', icon: 'FileCheck', color: '#3b82f6', description: 'InsurTech, Claims & Underwriting' },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', color: '#f59e0b', description: 'Smart Grid, Power & Renewables' },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Leaf', color: '#84cc16', description: 'AgriTech, Precision Farming & Supply' },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', color: '#06b6d4', description: 'EdTech, LMS & Institutional Tools' },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Factory', color: '#6366f1', description: 'Industrial Automation & Edge IoT' },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', color: '#14b8a6', description: 'Connected Vehicles & Telematics' },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', color: '#d97706', description: 'E-Commerce, Logistics & Smart Retail' },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', color: '#0284c7', description: 'Smart Contracts & Web3 Infrastructure' },
];
function inferContentType(type: string, subcategory?: string | null): string {
const sub = (subcategory || '').toLowerCase().trim();
if (sub === 'case study') return 'case_study';
if (sub === 'showcase') return 'showcase';
if (sub === 'news letter' || sub === 'marketing milestone') return 'newsletter';
if (sub === 'portfolio' || sub === 'company deck' || sub === 'product showcase' || sub === 'rnd innovation') return 'portfolio';
if (sub === 'mvp') return 'mvp';
if (sub === 'workflow automation' || sub === 'worlflow automation') return 'workflow';
if (sub === 'use case') return 'use_case';
if (sub === 'test drive resources') return 'test_drive';
if (type === 'case_study') return 'case_study';
if (type === 'url') return 'showcase';
if (type.includes('pdf') || type.includes('document') || type.includes('word')) return 'document';
if (type.includes('sheet') || type.includes('csv') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.includes('image')) return 'image';
return 'document';
}
function matchVerticalSlugs(title: string, description?: string | null): string[] {
const text = `${title} ${description || ''}`.toLowerCase();
const matched = new Set<string>();
if (/cybersecurity|security|threat|ot |defence|defense|ransomware|audit|hacker|firewall|fpga|compliance|kyc|aml/.test(text)) {
matched.add('cybersecurity');
}
if (/ai|machine learning|resnet|densenet|nlp|chatbot|genai|deep learning|prediction|predictive|forecasting|gpt|n8n|rag|speech/.test(text)) {
matched.add('ai-ml');
}
if (/health|medical|pharma|drug|cancer|hospital|patient|doctor|eye|blood|hematology|x-ray|brain tumor|bio|biotech|wearable|ventilator|ct scan/.test(text)) {
matched.add('healthcare-pharma');
}
if (/bank|fintech|payment|fraud|credit|loan|financial|accounting|cash|revenue|investor|audit|trade|b2b/.test(text)) {
matched.add('finance-banking');
}
if (/insurance|claims|underwriting|insurtech|policy|catastrophe|actuary/.test(text)) {
matched.add('insurance');
}
if (/energy|grid|power|renewable|ev |electric vehicle|utility|utilities|battery|charging|power plant|oms|ems|metering|solar|wind/.test(text)) {
matched.add('energy-utilities');
}
if (/agri|farm|crop|livestock|soil|aqua|pest|aquaponics|harvest|yield/.test(text)) {
matched.add('agriculture');
}
if (/education|student|learning|lms|classroom|exam|academic|textbook|proctoring|university|school/.test(text)) {
matched.add('education');
}
if (/manufactur|industrial|iot|edge|predictive maintenance|digital twin|esp32|ble board|pcb|robotics|factory|plc/.test(text)) {
matched.add('manufacturing-iot');
}
if (/vehicle|automotive|fleet|telematics|adas|car|driving|mobility|maas/.test(text)) {
matched.add('automotive');
}
if (/retail|e-commerce|supply chain|inventory|mall|store|basket|procurement|logistics|fmcg|pos/.test(text)) {
matched.add('retail-supply-chain');
}
if (/blockchain|smart contract|credentialing|traceability/.test(text)) {
matched.add('blockchain');
}
return Array.from(matched);
}
async function main() {
console.log('🚀 Starting Data Normalization & Taxonomy Seeding...');
// 1. Seed Verticals
const verticalMap = new Map<string, string>(); // slug -> id
for (const v of INITIAL_VERTICALS) {
const upserted = await prisma.vertical.upsert({
where: { slug: v.slug },
update: { name: v.name, icon: v.icon, color: v.color, description: v.description },
create: v,
});
verticalMap.set(v.slug, upserted.id);
}
console.log(`${verticalMap.size} Verticals ready.`);
// 2. Fix typos in Subcategories
await prisma.asset.updateMany({
where: { subcategory: { in: ['Showcaase', 'showcase'] } },
data: { subcategory: 'Showcase' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Worlflow Automation' },
data: { subcategory: 'Workflow Automation' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Use Case', categoryId: 'Marketing' },
data: { categoryId: 'Technical' },
});
await prisma.asset.updateMany({
where: { subcategory: 'MVP', categoryId: 'Presentations' },
data: { categoryId: 'Resources' },
});
console.log('✅ Subcategory typos & category alignments fixed.');
// 3. Process all assets
const assets = await prisma.asset.findMany();
let updatedCount = 0;
for (const asset of assets) {
const contentType = inferContentType(asset.type, asset.subcategory);
const matchedSlugs = matchVerticalSlugs(asset.title, asset.description);
const verticalIds = matchedSlugs.map(slug => verticalMap.get(slug)).filter(Boolean) as string[];
await prisma.asset.update({
where: { id: asset.id },
data: {
contentType,
verticals: {
set: verticalIds.map(id => ({ id })),
},
},
});
updatedCount++;
}
console.log(`🎉 Successfully normalized ${updatedCount} assets with content types & vertical tags!`);
}
main()
.catch(err => {
console.error('❌ Migration failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

26
Channel-Backend/seed.js Normal file
View File

@ -0,0 +1,26 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function seed() {
await prisma.legalDocument.create({
data: {
type: 'NDA',
version: '1.0',
content: 'This is the standard Non-Disclosure Agreement content...',
isActive: true,
}
});
await prisma.legalDocument.create({
data: {
type: 'MSA',
version: '1.0',
content: 'This is the standard Master Services Agreement content...',
isActive: true,
}
});
console.log('Documents seeded.');
}
seed().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -2,7 +2,6 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
import prisma from './src/utils/db'; import prisma from './src/utils/db';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { seedFourGroupTaxonomy } from './src/utils/seed-taxonomy';
async function seed() { async function seed() {
console.log('Starting database seeding...'); console.log('Starting database seeding...');
@ -198,9 +197,6 @@ async function seed() {
} }
console.log('Seeded Ecosystem Offerings.'); console.log('Seeded Ecosystem Offerings.');
// 6. Seed 4-Group Asset Taxonomy
await seedFourGroupTaxonomy();
console.log('Seeding completed successfully.'); console.log('Seeding completed successfully.');
} }

View File

@ -16,10 +16,6 @@ import assetRoutes from './routes/asset.routes';
import orgRoutes from './routes/organization.routes'; import orgRoutes from './routes/organization.routes';
import legalRoutes from './routes/legal.routes'; import legalRoutes from './routes/legal.routes';
import ecosystemRoutes from './routes/ecosystem.routes'; import ecosystemRoutes from './routes/ecosystem.routes';
import taxonomyRoutes from './routes/taxonomy.routes';
import notificationRoutes from './routes/notification.routes';
import chatRoutes from './routes/chat.routes';
import branchRoutes from './routes/branch.routes';
import { ensureBucketExists } from './utils/s3'; import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage'; import { originStorage } from './utils/origin-storage';
@ -110,13 +106,9 @@ app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunc
// API Routes // API Routes
app.use('/api/v1/auth', authRoutes); app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes); app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/assets', notificationRoutes);
app.use('/api/v1/organizations', orgRoutes); app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes); app.use('/api/v1/legal', legalRoutes);
app.use('/api/v1/ecosystem', ecosystemRoutes); app.use('/api/v1/ecosystem', ecosystemRoutes);
app.use('/api/v1/taxonomy', taxonomyRoutes);
app.use('/api/v1/chat', chatRoutes);
app.use('/api/v1', branchRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => { app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' }); res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -1,29 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './utils/db';
import { ChatService } from './services/chat.service';
async function main() {
console.log('[Auto-Indexing] Starting catalog RAG auto-indexing for restored assets...');
const chatService = new ChatService();
const assets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
console.log(`[Auto-Indexing] Found ${assets.length} assets enabled for Knowledge Base.`);
const assetIds = assets.map(a => a.id);
await chatService.autoIndexCatalog(assetIds);
const embeddingCount = await prisma.assetEmbedding.count();
console.log(`[Auto-Indexing] Successfully indexed ${embeddingCount} OKF vector embedding chunks into database.`);
process.exit(0);
}
main().catch((err) => {
console.error('[Auto-Indexing] Failed:', err);
process.exit(1);
});

View File

@ -59,19 +59,6 @@ export class AssetController {
fileUrl = req.body.url; fileUrl = req.body.url;
} }
const parseJsonOrArray = (val: any) => {
if (!val) return undefined;
if (typeof val === 'string' && val.trim()) {
try { return JSON.parse(val); } catch { return val.split(',').map((id: string) => id.trim()).filter(Boolean); }
}
return val;
};
let verticalIds = parseJsonOrArray(req.body.verticalIds);
let techStackIds = parseJsonOrArray(req.body.techStackIds);
let engagementTypeIds = parseJsonOrArray(req.body.engagementTypeIds);
let complianceIds = parseJsonOrArray(req.body.complianceIds);
const assetData = { const assetData = {
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'), title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype), type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype),
@ -88,10 +75,6 @@ export class AssetController {
thumbnailUrl: req.body.thumbnailUrl || null, thumbnailUrl: req.body.thumbnailUrl || null,
problemStatement: req.body.problemStatement || null, problemStatement: req.body.problemStatement || null,
solution: req.body.solution || null, solution: req.body.solution || null,
verticalIds,
techStackIds,
engagementTypeIds,
complianceIds,
shares, shares,
sharedOrgIds: req.body.sharedOrgIds || null, sharedOrgIds: req.body.sharedOrgIds || null,
}; };
@ -104,18 +87,7 @@ export class AssetController {
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => { public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined; const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
const filters = { const assets = await this.assetService.getAssets(userContext);
search: req.query.search as string,
verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined,
techStackIds: req.query.techStackIds ? (req.query.techStackIds as string).split(',') : undefined,
engagementTypeIds: req.query.engagementTypeIds ? (req.query.engagementTypeIds as string).split(',') : undefined,
complianceIds: req.query.complianceIds ? (req.query.complianceIds as string).split(',') : undefined,
contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined,
subcategories: req.query.subcategories ? (req.query.subcategories as string).split(',') : undefined,
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
sortBy: req.query.sortBy as any,
};
const assets = await this.assetService.getAssets(userContext, filters);
res.status(200).json(assets); res.status(200).json(assets);
} catch(err) { next(err); } } catch(err) { next(err); }
} }

View File

@ -1,39 +0,0 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { BranchService } from '../services/branch.service';
const branchService = new BranchService();
export class BranchController {
public createBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branch = await branchService.createBranch(organizationId, req.body);
res.status(201).json(branch);
} catch (err) { next(err); }
};
public getBranches = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branches = await branchService.getOrganizationBranches(organizationId);
res.status(200).json(branches);
} catch (err) { next(err); }
};
public updateBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const branch = await branchService.updateBranch(id, req.body);
res.status(200).json(branch);
} catch (err) { next(err); }
};
public deleteBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await branchService.deleteBranch(id);
res.status(200).json({ message: 'Branch deleted successfully' });
} catch (err) { next(err); }
};
}

View File

@ -1,103 +0,0 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { ChatService } from '../services/chat.service';
import prisma from '../utils/db';
const chatService = new ChatService();
export class ChatController {
public queryChat = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { prompt, sessionId, attachedEntities } = req.body;
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await chatService.processPrompt(userId, prompt || 'Analyze attached workbench items', sessionId, attachedEntities);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
public getHistory = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const sessions = await prisma.chatSession.findMany({
where: { userId },
include: {
messages: {
take: 2,
orderBy: { createdAt: 'asc' }
}
},
orderBy: { updatedAt: 'desc' },
take: 20,
});
res.status(200).json(sessions);
} catch (err) {
next(err);
}
};
public createSession = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.create({
data: { userId }
});
res.status(201).json({ sessionId: session.id, session });
} catch (err) {
next(err);
}
};
public getSessionMessages = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
const { sessionId } = req.params;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.findFirst({
where: { id: sessionId, userId },
include: {
messages: {
orderBy: { createdAt: 'asc' }
}
}
});
if (!session) {
return res.status(404).json({ error: 'Chat session not found' });
}
res.status(200).json(session);
} catch (err) {
next(err);
}
};
public syncAssetKnowledge = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetId } = req.params;
await chatService.ingestAssetKnowledge(assetId);
res.status(200).json({ message: 'Asset knowledge re-indexed successfully' });
} catch (err) {
next(err);
}
};
}

View File

@ -1,72 +0,0 @@
import { Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
import { MailService } from '../services/mail.service';
export class NotificationController {
private mailService = new MailService();
public sendAssetAnnouncement = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { title, message, targetOrgIds, assetIds } = req.body;
if (!title || !message) {
return res.status(400).json({ error: 'Title and message are required' });
}
let senderEmail = 'admin@tech4biz.com';
if (req.user?.userId) {
const adminUser = await prisma.user.findUnique({ where: { id: req.user.userId }, select: { email: true } });
if (adminUser?.email) senderEmail = adminUser.email;
}
// Log notification record
const notification = await prisma.assetNotification.create({
data: {
title,
message,
sentBy: senderEmail,
targetOrgIds: Array.isArray(targetOrgIds) ? targetOrgIds : ['ALL'],
assetIds: Array.isArray(assetIds) ? assetIds : [],
}
});
// Find recipient users
const userWhere: any = { role: 'PARTNER_USER' };
if (Array.isArray(targetOrgIds) && targetOrgIds.length > 0 && !targetOrgIds.includes('ALL')) {
userWhere.organizationId = { in: targetOrgIds };
}
const partnerUsers = await prisma.user.findMany({
where: userWhere,
select: { email: true }
});
const recipientEmails = partnerUsers.map(u => u.email).filter(Boolean);
// Trigger email notifications asynchronously via mailService
if (recipientEmails.length > 0) {
this.mailService.sendCustomAnnouncement({
recipients: recipientEmails,
subject: `📢 Asset Announcement: ${title}`,
messageBody: message,
}).catch(err => console.error('Failed to dispatch announcement emails:', err));
}
res.status(201).json({
message: 'Notification sent successfully',
recipientsCount: recipientEmails.length,
notification,
});
} catch (err) { next(err); }
};
public getNotificationLogs = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const logs = await prisma.assetNotification.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
res.status(200).json(logs);
} catch (err) { next(err); }
};
}

View File

@ -1,207 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
export class TaxonomyController {
// Public/Authenticated: Get all active verticals
public getVerticals = async (req: Request, res: Response, next: NextFunction) => {
try {
const verticals = await prisma.vertical.findMany({
where: { isActive: true },
include: {
_count: {
select: { assets: true }
}
},
orderBy: { orderIndex: 'asc' },
});
res.status(200).json(verticals);
} catch (err) { next(err); }
};
// Public/Authenticated: Get taxonomy metadata with real-time asset counts across all 4 groups
public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => {
try {
const [verticals, techStacks, engagementTypes, complianceStandards, assets] = await Promise.all([
prisma.vertical.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.techStack.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.engagementType.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.complianceStandard.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.asset.findMany({
select: {
categoryId: true,
subcategory: true,
contentType: true,
tags: true,
}
})
]);
const categoryCounts: Record<string, number> = {};
const subcategoryCounts: Record<string, number> = {};
const contentTypeCounts: Record<string, number> = {};
const tagCounts: Record<string, number> = {};
assets.forEach(a => {
if (a.categoryId) {
categoryCounts[a.categoryId] = (categoryCounts[a.categoryId] || 0) + 1;
}
if (a.subcategory) {
subcategoryCounts[a.subcategory] = (subcategoryCounts[a.subcategory] || 0) + 1;
}
if (a.contentType) {
contentTypeCounts[a.contentType] = (contentTypeCounts[a.contentType] || 0) + 1;
}
if (Array.isArray(a.tags)) {
a.tags.forEach(t => {
if (t) tagCounts[t] = (tagCounts[t] || 0) + 1;
});
}
});
res.status(200).json({
verticals,
techStacks,
engagementTypes,
complianceStandards,
categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })),
subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })),
contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })),
tags: Object.entries(tagCounts).map(([name, count]) => ({ name, count })),
totalAssets: assets.length,
});
} catch (err) { next(err); }
};
// Admin: Create Vertical
public createVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const vertical = await prisma.vertical.create({
data: { name, slug, icon, description, color }
});
res.status(201).json(vertical);
} catch (err) { next(err); }
};
// Admin: Update Vertical
public updateVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { name, icon, description, color, isActive, orderIndex } = req.body;
const data: any = {};
if (name !== undefined) {
data.name = name;
data.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
}
if (icon !== undefined) data.icon = icon;
if (description !== undefined) data.description = description;
if (color !== undefined) data.color = color;
if (isActive !== undefined) data.isActive = isActive;
if (orderIndex !== undefined) data.orderIndex = orderIndex;
const vertical = await prisma.vertical.update({
where: { id },
data,
});
res.status(200).json(vertical);
} catch (err) { next(err); }
};
// Admin: Delete Vertical
public deleteVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.vertical.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Tech Stack
public createTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, category, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.techStack.create({
data: { name, slug, category: category || 'Languages & Frameworks', icon, description, color: color || '#64748b' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Tech Stack
public deleteTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.techStack.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Engagement Type
public createEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.engagementType.create({
data: { name, slug, icon, description, color: color || '#0284c7' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Engagement Type
public deleteEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.engagementType.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Compliance Standard
public createComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.complianceStandard.create({
data: { name, slug, icon, description, color: color || '#10b981' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Compliance Standard
public deleteComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.complianceStandard.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
}

View File

@ -1,15 +0,0 @@
import { Router } from 'express';
import { BranchController } from '../controllers/branch.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const branchController = new BranchController();
router.use(authenticate);
router.get('/organizations/:organizationId/branches', branchController.getBranches);
router.post('/organizations/:organizationId/branches', requireRole('ADMIN'), branchController.createBranch);
router.put('/branches/:id', requireRole('ADMIN'), branchController.updateBranch);
router.delete('/branches/:id', requireRole('ADMIN'), branchController.deleteBranch);
export default router;

View File

@ -1,16 +0,0 @@
import { Router } from 'express';
import { ChatController } from '../controllers/chat.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const chatController = new ChatController();
router.use(authenticate);
router.post('/query', chatController.queryChat);
router.get('/history', chatController.getHistory);
router.post('/sessions', chatController.createSession);
router.get('/sessions/:sessionId', chatController.getSessionMessages);
router.post('/sync/:assetId', requireRole('ADMIN'), chatController.syncAssetKnowledge);
export default router;

View File

@ -1,11 +0,0 @@
import { Router } from 'express';
import { NotificationController } from '../controllers/notification.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new NotificationController();
router.post('/notify', authenticate, requireRole('ADMIN'), controller.sendAssetAnnouncement);
router.get('/logs', authenticate, requireRole('ADMIN'), controller.getNotificationLogs);
export default router;

View File

@ -1,24 +0,0 @@
import { Router } from 'express';
import { TaxonomyController } from '../controllers/taxonomy.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new TaxonomyController();
router.get('/verticals', authenticate, controller.getVerticals);
router.get('/meta', authenticate, controller.getTaxonomyMeta);
router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createVertical);
router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical);
router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical);
router.post('/tech-stacks', authenticate, requireRole('ADMIN'), controller.createTechStack);
router.delete('/tech-stacks/:id', authenticate, requireRole('ADMIN'), controller.deleteTechStack);
router.post('/engagement-types', authenticate, requireRole('ADMIN'), controller.createEngagementType);
router.delete('/engagement-types/:id', authenticate, requireRole('ADMIN'), controller.deleteEngagementType);
router.post('/compliance-standards', authenticate, requireRole('ADMIN'), controller.createComplianceStandard);
router.delete('/compliance-standards/:id', authenticate, requireRole('ADMIN'), controller.deleteComplianceStandard);
export default router;

View File

@ -1,209 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './utils/db';
async function seedTaxonomy() {
console.log('[Taxonomy-Seed] Starting taxonomy re-seeding with exact user specifications...');
await prisma.vertical.deleteMany();
await prisma.techStack.deleteMany();
await prisma.engagementType.deleteMany();
await prisma.complianceStandard.deleteMany();
console.log('[Taxonomy-Seed] Cleared existing taxonomy tables.');
// 1. Group 1: Industry Verticals (11 Entries)
const verticalsData = [
{ name: 'Cybersecurity & OT Security', slug: 'cybersecurity-ot-security', icon: 'Shield', description: 'ICS/SCADA protection, threat detection, and OT security architecture.', color: '#ef4444', orderIndex: 1 },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Heart', description: 'Patient monitoring, clinical workflows, and pharma tech.', color: '#10b981', orderIndex: 2 },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'CreditCard', description: 'Core banking systems, fraud detection, and fintech platforms.', color: '#3b82f6', orderIndex: 3 },
{ name: 'Insurance', slug: 'insurance', icon: 'ShieldCheck', description: 'InsurTech systems, claim automation, and actuarial analytics.', color: '#0284c7', orderIndex: 4 },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', description: 'Grid monitoring, renewable management, and infrastructure tech.', color: '#06b6d4', orderIndex: 5 },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Sprout', description: 'AgriTech telemetry, precision farming, and supply analytics.', color: '#84cc16', orderIndex: 6 },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', description: 'EdTech platforms, AI tutoring, and campus management.', color: '#f59e0b', orderIndex: 7 },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Cpu', description: 'Predictive maintenance, IIoT telemetry, and smart factory tech.', color: '#8b5cf6', orderIndex: 8 },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', description: 'Connected vehicles, EV telemetry, and autonomous systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', description: 'Smart basket automation, inventory AI, and logistics.', color: '#f97316', orderIndex: 10 },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', description: 'Distributed ledgers, smart contracts, and Web3 security.', color: '#6366f1', orderIndex: 11 },
];
const createdVerticals: Record<string, string> = {};
for (const item of verticalsData) {
const v = await prisma.vertical.create({ data: item });
createdVerticals[item.name] = v.id;
}
// 2. Group 2: Technology Stack (21 Entries across 4 Categories)
const techStacksData = [
// Languages/Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', icon: 'Code', description: 'Enterprise backend services and Spring ecosystem.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', icon: 'Server', description: 'Event-driven JavaScript/TypeScript backend runtimes.', color: '#10b981', orderIndex: 2 },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', icon: 'FileCode', description: 'Data science, AI models, and microservices.', color: '#f59e0b', orderIndex: 3 },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', icon: 'Layout', description: 'Modern web component UIs and frontend state management.', color: '#06b6d4', orderIndex: 4 },
{ name: 'Go', slug: 'golang', category: 'Languages & Frameworks', icon: 'Cpu', description: 'High-performance cloud-native microservices.', color: '#0284c7', orderIndex: 5 },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', icon: 'Layers', description: 'C# enterprise applications and Microsoft ecosystem.', color: '#6366f1', orderIndex: 6 },
// AI/ML
{ name: 'AI & ML', slug: 'ai-ml', category: 'AI & ML', icon: 'Sparkles', description: 'Core artificial intelligence and machine learning models.', color: '#8b5cf6', orderIndex: 7 },
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', icon: 'Bot', description: 'Large language models, multi-agent frameworks, and reasoning engines.', color: '#a855f7', orderIndex: 8 },
{ name: 'RAG', slug: 'rag', category: 'AI & ML', icon: 'Database', description: 'Retrieval-Augmented Generation and vector search systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', icon: 'Camera', description: 'Real-time video analytics and optical recognition.', color: '#f43f5e', orderIndex: 10 },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', icon: 'GitBranch', description: 'MLOps, model retraining, and feature stores.', color: '#d946ef', orderIndex: 11 },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', icon: 'Eye', description: 'Synthetic media verification and anti-spoofing.', color: '#ef4444', orderIndex: 12 },
// Data/Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', icon: 'Database', description: 'Relational database with JSONB and vector capabilities.', color: '#3b82f6', orderIndex: 13 },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', icon: 'Clock', description: 'Durable workflow execution and saga orchestrations.', color: '#10b981', orderIndex: 14 },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', icon: 'Activity', description: 'Distributed event streaming and message pub/sub.', color: '#f59e0b', orderIndex: 15 },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', icon: 'Zap', description: 'Asynchronous event architecture and CQRS patterns.', color: '#06b6d4', orderIndex: 16 },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', icon: 'Grid', description: 'Decoupled service APIs and domain-driven design.', color: '#6366f1', orderIndex: 17 },
// Cloud/Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', icon: 'Cloud', description: 'Amazon Web Services cloud infrastructure.', color: '#f97316', orderIndex: 18 },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', icon: 'Lock', description: 'Sovereign cloud hosting and air-gapped on-premise deployments.', color: '#64748b', orderIndex: 19 },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', icon: 'Box', description: 'K8s container orchestration and mesh networking.', color: '#0284c7', orderIndex: 20 },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', icon: 'Server', description: 'Infrastructure-as-a-Service and virtualized bare metal.', color: '#475569', orderIndex: 21 },
];
const createdTechStacks: Record<string, string> = {};
for (const item of techStacksData) {
const ts = await prisma.techStack.create({ data: item });
createdTechStacks[item.name] = ts.id;
}
// 3. Group 3: Engagement Type (4 Entries)
const engagementTypesData = [
{ name: 'Build', slug: 'build', icon: 'Wrench', description: 'Greenfield product engineering and 0-to-1 development.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Rescue', slug: 'rescue', icon: 'LifeBuoy', description: 'Turnaround engineering, legacy modernization, and critical fixes.', color: '#ef4444', orderIndex: 2 },
{ name: 'Scale', slug: 'scale', icon: 'TrendingUp', description: 'Performance optimization, architecture scaling, and throughput expansion.', color: '#10b981', orderIndex: 3 },
{ name: 'Due Diligence', slug: 'due-diligence', icon: 'FileSearch', description: 'Technical audits, code reviews, and M&A architecture assessments.', color: '#f59e0b', orderIndex: 4 },
];
const createdEngagementTypes: Record<string, string> = {};
for (const item of engagementTypesData) {
const et = await prisma.engagementType.create({ data: item });
createdEngagementTypes[item.name] = et.id;
}
// 4. Group 4: Compliance / Regulatory (5 Entries)
const complianceStandardsData = [
{ name: 'HIPAA', slug: 'hipaa', icon: 'Activity', description: 'Health Insurance Portability and Accountability Act.', color: '#ec4899', orderIndex: 1 },
{ name: 'GxP', slug: 'gxp', icon: 'ShieldCheck', description: 'Good Practice quality guidelines for pharma and life sciences.', color: '#10b981', orderIndex: 2 },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', icon: 'Building', description: 'APRA Operational Risk Management standard for banking.', color: '#3b82f6', orderIndex: 3 },
{ name: 'SOC 2', slug: 'soc-2', icon: 'FileCheck', description: 'SOC 2 security, availability, and confidentiality controls.', color: '#06b6d4', orderIndex: 4 },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', icon: 'Lock', description: 'EU General Data Protection Regulation and data sovereignty.', color: '#8b5cf6', orderIndex: 5 },
];
const createdCompliance: Record<string, string> = {};
for (const item of complianceStandardsData) {
const cs = await prisma.complianceStandard.create({ data: item });
createdCompliance[item.name] = cs.id;
}
console.log('[Taxonomy-Seed] Successfully seeded 11 Verticals, 21 Tech Stacks, 4 Engagement Types, and 5 Compliance Standards.');
// 5. Re-map Catalog Assets to the Exact Taxonomy Entries
const assets = await prisma.asset.findMany();
console.log(`[Taxonomy-Seed] Mapping exact taxonomy relations for ${assets.length} catalog assets...`);
let updatedCount = 0;
for (const asset of assets) {
const text = (asset.title + ' ' + (asset.description || '') + ' ' + (asset.tags || []).join(' ')).toLowerCase();
const targetVerticals: string[] = [];
const targetTechs: string[] = [];
const targetEngagements: string[] = [];
const targetCompliance: string[] = [];
// Verticals mapping
if (text.includes('cyber') || text.includes('security') || text.includes('scada') || text.includes('ot security')) {
if (createdVerticals['Cybersecurity & OT Security']) targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (text.includes('health') || text.includes('patient') || text.includes('medical') || text.includes('pharma') || text.includes('diabetic')) {
if (createdVerticals['Healthcare & Pharma']) targetVerticals.push(createdVerticals['Healthcare & Pharma']);
}
if (text.includes('bank') || text.includes('finance') || text.includes('fintech') || text.includes('payment')) {
if (createdVerticals['Finance & Banking']) targetVerticals.push(createdVerticals['Finance & Banking']);
}
if (text.includes('insurance') || text.includes('claim')) {
if (createdVerticals['Insurance']) targetVerticals.push(createdVerticals['Insurance']);
}
if (text.includes('energy') || text.includes('grid') || text.includes('metering') || text.includes('utility') || text.includes('water')) {
if (createdVerticals['Energy & Utilities']) targetVerticals.push(createdVerticals['Energy & Utilities']);
}
if (text.includes('agri') || text.includes('farm') || text.includes('crop')) {
if (createdVerticals['Agriculture']) targetVerticals.push(createdVerticals['Agriculture']);
}
if (text.includes('student') || text.includes('education') || text.includes('textbook') || text.includes('school') || text.includes('plagiarism')) {
if (createdVerticals['Education']) targetVerticals.push(createdVerticals['Education']);
}
if (text.includes('manufactur') || text.includes('iot') || text.includes('sensor') || text.includes('factory')) {
if (createdVerticals['Manufacturing & IoT']) targetVerticals.push(createdVerticals['Manufacturing & IoT']);
}
if (text.includes('auto') || text.includes('vehicle') || text.includes('car') || text.includes('ev ')) {
if (createdVerticals['Automotive']) targetVerticals.push(createdVerticals['Automotive']);
}
if (text.includes('retail') || text.includes('basket') || text.includes('store') || text.includes('supply')) {
if (createdVerticals['Retail & Supply Chain']) targetVerticals.push(createdVerticals['Retail & Supply Chain']);
}
if (text.includes('blockchain') || text.includes('ledger') || text.includes('web3')) {
if (createdVerticals['Blockchain']) targetVerticals.push(createdVerticals['Blockchain']);
}
// Tech Stacks mapping
if (text.includes('ai') || text.includes('ml') || text.includes('model') || text.includes('predictive') || text.includes('chatbot')) {
if (createdTechStacks['AI & ML']) targetTechs.push(createdTechStacks['AI & ML']);
}
if (text.includes('llm') || text.includes('agent') || text.includes('gpt') || text.includes('deepseek')) {
if (createdTechStacks['LLM / Agentic']) targetTechs.push(createdTechStacks['LLM / Agentic']);
}
if (text.includes('rag') || text.includes('vector') || text.includes('retrieval')) {
if (createdTechStacks['RAG']) targetTechs.push(createdTechStacks['RAG']);
}
if (text.includes('vision') || text.includes('camera') || text.includes('image')) {
if (createdTechStacks['Computer Vision']) targetTechs.push(createdTechStacks['Computer Vision']);
}
if (text.includes('postgres') || text.includes('db') || text.includes('sql')) {
if (createdTechStacks['PostgreSQL']) targetTechs.push(createdTechStacks['PostgreSQL']);
}
if (text.includes('aws') || text.includes('cloud') || text.includes('server')) {
if (createdTechStacks['AWS']) targetTechs.push(createdTechStacks['AWS']);
}
// Default Fallbacks
if (targetVerticals.length === 0 && createdVerticals['Cybersecurity & OT Security']) {
targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (targetTechs.length === 0 && createdTechStacks['AI & ML']) {
targetTechs.push(createdTechStacks['AI & ML']);
}
if (createdEngagementTypes['Build']) {
targetEngagements.push(createdEngagementTypes['Build']);
}
if (createdCompliance['SOC 2']) {
targetCompliance.push(createdCompliance['SOC 2']);
}
await prisma.asset.update({
where: { id: asset.id },
data: {
verticals: { connect: targetVerticals.map(id => ({ id })) },
techStacks: { connect: targetTechs.map(id => ({ id })) },
engagementTypes: { connect: targetEngagements.map(id => ({ id })) },
complianceStandards: { connect: targetCompliance.map(id => ({ id })) },
}
});
updatedCount++;
}
console.log(`[Taxonomy-Seed] Successfully mapped exact taxonomy relations for ${updatedCount} assets.`);
process.exit(0);
}
seedTaxonomy().catch(err => {
console.error('[Taxonomy-Seed] Failed:', err);
process.exit(1);
});

View File

@ -55,7 +55,7 @@ export class AssetService {
} }
public async createAsset(data: any) { public async createAsset(data: any) {
const { sharedOrgIds, shares, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data; const { sharedOrgIds, shares, tags, ...rest } = data;
// Parse tags // Parse tags
let parsedTags: string[] = []; let parsedTags: string[] = [];
@ -69,36 +69,10 @@ export class AssetService {
} }
} }
const parseIds = (val: any): string[] => {
if (Array.isArray(val)) return val;
if (typeof val === 'string' && val.trim()) {
try { return JSON.parse(val); }
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
}
return [];
};
const parsedVerticalIds = parseIds(verticalIds);
const parsedTechStackIds = parseIds(techStackIds);
const parsedEngagementTypeIds = parseIds(engagementTypeIds);
const parsedComplianceIds = parseIds(complianceIds);
const asset = await prisma.asset.create({ const asset = await prisma.asset.create({
data: { data: {
...rest, ...rest,
tags: parsedTags, tags: parsedTags,
...(parsedVerticalIds.length > 0 ? {
verticals: { connect: parsedVerticalIds.map(id => ({ id })) }
} : {}),
...(parsedTechStackIds.length > 0 ? {
techStacks: { connect: parsedTechStackIds.map(id => ({ id })) }
} : {}),
...(parsedEngagementTypeIds.length > 0 ? {
engagementTypes: { connect: parsedEngagementTypeIds.map(id => ({ id })) }
} : {}),
...(parsedComplianceIds.length > 0 ? {
complianceStandards: { connect: parsedComplianceIds.map(id => ({ id })) }
} : {}),
} }
}); });
@ -164,27 +138,36 @@ export class AssetService {
return this.getAssetById(asset.id); return this.getAssetById(asset.id);
} }
public async getAssets( public async getAssets(userContext?: { role: string; userId: string }) {
userContext?: { role: string; userId: string },
filters?: {
search?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
) {
if (!userContext) { if (!userContext) {
return []; return [];
} }
const whereClause: any = {}; if (userContext.role === 'ADMIN') {
return await prisma.asset.findMany({
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
include: {
user: {
select: { id: true, email: true }
}
}
}
},
orderBy: { createdAt: 'desc' }
});
}
if (userContext.role !== 'ADMIN') {
// For clients/partners, find user organization first // For clients/partners, find user organization first
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userContext.userId } where: { id: userContext.userId }
@ -199,8 +182,9 @@ export class AssetService {
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase()) ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: []; : [];
whereClause.status = 'published'; const whereClause: any = {
whereClause.OR = [ status: 'published',
OR: [
{ {
sharedWith: { sharedWith: {
some: { some: {
@ -212,7 +196,8 @@ export class AssetService {
} }
} }
} }
]; ]
};
if (userGroups.length > 0) { if (userGroups.length > 0) {
whereClause.OR.push({ whereClause.OR.push({
@ -226,92 +211,10 @@ export class AssetService {
} }
}); });
} }
}
// Apply Filter Criteria (Additive AND logic)
const andConditions: any[] = [];
if (filters?.search && filters.search.trim()) {
const q = filters.search.trim();
andConditions.push({
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ subcategory: { contains: q, mode: 'insensitive' } },
{ categoryId: { contains: q, mode: 'insensitive' } },
{ tags: { has: q } },
]
});
}
if (filters?.verticalIds && filters.verticalIds.length > 0) {
andConditions.push({
verticals: {
some: { id: { in: filters.verticalIds } }
}
});
}
if (filters?.techStackIds && filters.techStackIds.length > 0) {
andConditions.push({
techStacks: {
some: { id: { in: filters.techStackIds } }
}
});
}
if (filters?.engagementTypeIds && filters.engagementTypeIds.length > 0) {
andConditions.push({
engagementTypes: {
some: { id: { in: filters.engagementTypeIds } }
}
});
}
if (filters?.complianceIds && filters.complianceIds.length > 0) {
andConditions.push({
complianceStandards: {
some: { id: { in: filters.complianceIds } }
}
});
}
if (filters?.contentTypes && filters.contentTypes.length > 0) {
andConditions.push({
contentType: { in: filters.contentTypes }
});
}
if (filters?.subcategories && filters.subcategories.length > 0) {
andConditions.push({
subcategory: { in: filters.subcategories }
});
}
if (filters?.tags && filters.tags.length > 0) {
andConditions.push({
tags: { hasSome: filters.tags }
});
}
if (andConditions.length > 0) {
whereClause.AND = andConditions;
}
// Order By
let orderBy: any = { createdAt: 'desc' };
if (filters?.sortBy === 'oldest') orderBy = { createdAt: 'asc' };
else if (filters?.sortBy === 'title_asc') orderBy = { title: 'asc' };
else if (filters?.sortBy === 'title_desc') orderBy = { title: 'desc' };
else if (filters?.sortBy === 'type') orderBy = { type: 'asc' };
return await prisma.asset.findMany({ return await prisma.asset.findMany({
where: whereClause, where: whereClause,
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -322,15 +225,11 @@ export class AssetService {
} }
} }
}, },
downloadRequests: userContext.role === 'ADMIN' ? { downloadRequests: {
include: {
user: { select: { id: true, email: true } }
}
} : {
where: { userId: userContext.userId } where: { userId: userContext.userId }
} }
}, },
orderBy, orderBy: { createdAt: 'desc' }
}); });
} }
@ -338,10 +237,6 @@ export class AssetService {
return await prisma.asset.findUnique({ return await prisma.asset.findUnique({
where: { id }, where: { id },
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -364,7 +259,7 @@ export class AssetService {
} }
public async updateAsset(id: string, data: any) { public async updateAsset(id: string, data: any) {
const { shares, sharedOrgIds, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data; const { sharedOrgIds, shares, tags, ...rest } = data;
const updateData: any = { ...rest }; const updateData: any = { ...rest };
@ -382,28 +277,6 @@ export class AssetService {
updateData.tags = parsedTags; updateData.tags = parsedTags;
} }
const parseIds = (val: any): string[] => {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); }
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
}
return [];
};
if (verticalIds !== undefined) {
updateData.verticals = { set: parseIds(verticalIds).map(vid => ({ id: vid })) };
}
if (techStackIds !== undefined) {
updateData.techStacks = { set: parseIds(techStackIds).map(tid => ({ id: tid })) };
}
if (engagementTypeIds !== undefined) {
updateData.engagementTypes = { set: parseIds(engagementTypeIds).map(eid => ({ id: eid })) };
}
if (complianceIds !== undefined) {
updateData.complianceStandards = { set: parseIds(complianceIds).map(cid => ({ id: cid })) };
}
await prisma.asset.update({ await prisma.asset.update({
where: { id }, where: { id },
data: updateData data: updateData

View File

@ -1,38 +0,0 @@
import prisma from '../utils/db';
export class BranchService {
public async createBranch(organizationId: string, data: { name: string; code?: string }) {
return prisma.branch.create({
data: {
name: data.name,
code: data.code || null,
organizationId,
}
});
}
public async getOrganizationBranches(organizationId: string) {
return prisma.branch.findMany({
where: { organizationId },
include: {
_count: {
select: { users: true, sharedAssets: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
public async updateBranch(id: string, data: { name?: string; code?: string }) {
return prisma.branch.update({
where: { id },
data,
});
}
public async deleteBranch(id: string) {
return prisma.branch.delete({
where: { id }
});
}
}

View File

@ -1,705 +0,0 @@
import prisma from '../utils/db';
import axios from 'axios';
import { ExtractionService, OKFMetadata } from './extraction.service';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export class ChatService {
private extractionService = new ExtractionService();
private gatewayUrl = process.env.LLM_GATEWAY_URL || 'https://llm-devs.tech4biz.org/v1';
private apiKey = process.env.LLM_API_KEY || 'sk-TM-gxAudDVRTY-kq7_enEA';
private modelId = process.env.LLM_MODEL_ID || 'deepseek-chat';
/**
* Process a user chat prompt with RBAC-scoped, unified RAG retrieval across Assets, Showcase Reels, Ecosystem Offerings, and Legal Documents.
*/
public async processPrompt(
userId: string,
prompt: string,
sessionId?: string,
attachedEntities?: Array<{
id: string;
title: string;
type: string;
entityKind: 'ASSET' | 'SHOWCASE' | 'ECOSYSTEM' | 'LEGAL';
url?: string;
description?: string;
problemStatement?: string;
solution?: string;
thumbnailUrl?: string;
tags?: string[];
}>
) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
sharedAssets: true,
assignedNda: true,
assignedMsa: true,
}
});
if (!user) {
throw new Error('User session not found');
}
const partnerGroup = user.partnerGroup ? user.partnerGroup.trim().toLowerCase() : '';
const isClient = user.role === 'PARTNER_USER';
// 1. Determine accessible catalog asset IDs
let accessibleAssetIds: string[] = [];
if (user.role === 'ADMIN') {
const allAssets = await prisma.asset.findMany({ select: { id: true } });
accessibleAssetIds = allAssets.map(a => a.id);
} else {
const shared = await prisma.sharedAsset.findMany({
where: {
OR: [
{ organizationId: user.organizationId || '' },
{ userId: user.id },
user.branchId ? { branchId: user.branchId } : {},
]
},
select: { assetId: true }
});
const sharedIds = shared.map(s => s.assetId);
let groupIds: string[] = [];
if (partnerGroup) {
const groups = await prisma.assetGroup.findMany({
include: { assets: { select: { id: true } } }
});
const matchedGroup = groups.find(g => g.name.trim().toLowerCase() === partnerGroup);
if (matchedGroup) {
groupIds = matchedGroup.assets.map(a => a.id);
}
}
const publicAssets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
const publicIds = publicAssets.map(a => a.id);
accessibleAssetIds = Array.from(new Set([...sharedIds, ...groupIds, ...publicIds]));
}
// 2. Fetch Embeddings & Data Sources
let embeddingsCount = await prisma.assetEmbedding.count({
where: { assetId: { in: accessibleAssetIds } }
});
if (embeddingsCount === 0 && accessibleAssetIds.length > 0) {
await this.autoIndexCatalog(accessibleAssetIds);
}
const embeddings = await prisma.assetEmbedding.findMany({
where: {
assetId: { in: accessibleAssetIds },
asset: { includeInKnowledgeBase: true }
},
include: {
asset: {
select: {
id: true,
title: true,
type: true,
tags: true,
description: true,
problemStatement: true,
solution: true,
assetGroups: { select: { name: true } },
verticals: { select: { name: true } },
techStacks: { select: { name: true } },
complianceStandards: { select: { name: true } },
}
}
}
});
// 3. Fetch Featured Content (ContentShowcase) & Ecosystem Offerings
const showcaseItems = await prisma.contentShowcase.findMany({
where: { isActive: true }
});
const ecosystemOfferings = await prisma.ecosystemOffering.findMany({
where: { isActive: true }
});
// 4. Hybrid Search Engine with Normalized Fuzzy Term Matching
const normalizeStr = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
const promptLower = prompt.toLowerCase().trim();
const promptNorm = normalizeStr(prompt);
const promptTerms = promptLower.split(/\s+/).filter(w => w.length > 2);
const promptVector = JSON.parse(this.extractionService.generateEmbedding(prompt));
const citationsMap = new Map<string, CitationItem>();
const contextLines: string[] = [];
const hasAttachedEntities = attachedEntities && attachedEntities.length > 0;
// Explicit Attached Entities Ingestion (Drag-and-Drop AI Workbench)
if (hasAttachedEntities) {
for (const ent of attachedEntities!) {
if (!ent || !ent.id) continue;
try {
if (ent.entityKind === 'ASSET') {
const dbAsset = await prisma.asset.findUnique({
where: { id: ent.id },
include: {
verticals: true,
techStacks: true,
complianceStandards: true,
}
}).catch(() => null);
if (dbAsset) {
citationsMap.set(dbAsset.id, {
assetId: dbAsset.id,
title: dbAsset.title,
location: 'Inspected Catalog Asset',
type: dbAsset.type,
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ASSET] Title: "${dbAsset.title}" | Type: "${dbAsset.type}" | Link/URL: "${dbAsset.url}" | Category: "${dbAsset.categoryId || 'General'}" | Subcategory: "${dbAsset.subcategory || '-'}" | Description: "${dbAsset.description || 'N/A'}" | Problem Statement: "${dbAsset.problemStatement || 'N/A'}" | Solution Overview: "${dbAsset.solution || 'N/A'}" | Industry Verticals: "${(dbAsset.verticals || []).map(v => v.name).join(', ')}" | Tech Stack: "${(dbAsset.techStacks || []).map(t => t.name).join(', ')}" | Compliance Standards: "${(dbAsset.complianceStandards || []).map(c => c.name).join(', ')}"\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Inspected Catalog Asset',
type: ent.type || 'ASSET',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ASSET] Title: "${ent.title}" | Type: "${ent.type || 'Asset'}" | Description: "${ent.description || 'N/A'}" | Problem Statement: "${ent.problemStatement || 'N/A'}" | Solution Overview: "${ent.solution || 'N/A'}"\n`
);
}
} else if (ent.entityKind === 'SHOWCASE') {
const dbShowcase = await prisma.contentShowcase.findUnique({
where: { id: ent.id }
}).catch(() => null);
if (dbShowcase) {
citationsMap.set(dbShowcase.id, {
assetId: dbShowcase.id,
title: dbShowcase.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED FEATURED REEL] Title: "${dbShowcase.title}" | Video URL: "${dbShowcase.youtubeUrl}" | Description:\n${dbShowcase.description || 'Interactive product reel'}\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED FEATURED REEL] Title: "${ent.title}" | Video URL: "${ent.url || ''}" | Description:\n${ent.description || 'Interactive product reel'}\n`
);
}
} else if (ent.entityKind === 'ECOSYSTEM') {
const dbOffering = await prisma.ecosystemOffering.findUnique({
where: { id: ent.id }
}).catch(() => null);
if (dbOffering) {
citationsMap.set(dbOffering.id, {
assetId: dbOffering.id,
title: dbOffering.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ECOSYSTEM OFFERING] Name: "${dbOffering.name}" | Type: "${dbOffering.type}" | Tagline: "${dbOffering.tagline}" | Website URL: "${dbOffering.websiteUrl}" | Key Benefits: ${((dbOffering.benefits as string[]) || []).join('; ')} | Description:\n${dbOffering.description}\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ECOSYSTEM OFFERING] Name: "${ent.title}" | Description:\n${ent.description || 'Enterprise Ecosystem Offering'}\n`
);
}
} else if (ent.entityKind === 'LEGAL') {
const dbLegal = await prisma.legalDocument.findFirst({
where: { OR: [{ id: ent.id }, { type: ent.title.includes('NDA') ? 'NDA' : 'MSA' }] }
}).catch(() => null);
if (dbLegal) {
citationsMap.set(dbLegal.id, {
assetId: dbLegal.id,
title: ent.title,
location: 'Legal Agreement',
type: 'legal',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED LEGAL AGREEMENT] Type: "${dbLegal.type}" | Version: "${dbLegal.version}" | Content:\n${dbLegal.content}\n`
);
}
}
} catch (err) {
console.error('Failed to parse entity payload in RAG pipeline', err);
}
}
} else {
// ONLY RUN RAG WHEN NO ENTITIES ARE ATTACHED
// A. Match ContentShowcase items (Only on explicit query or exact title match)
const isExplicitShowcaseQuery = promptLower.includes('showcase') || promptLower.includes('video') || promptLower.includes('reel') || promptLower.includes('featured content');
showcaseItems.forEach((sc) => {
const fullText = (sc.title + ' ' + (sc.description || '')).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isExactTitleMatch = promptLower.includes(sc.title.toLowerCase()) || sc.title.toLowerCase().includes(promptLower);
if (isExactTitleMatch || (isExplicitShowcaseQuery && matchCount >= 2)) {
citationsMap.set(sc.id, {
assetId: sc.id,
title: sc.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[Featured Content Reel] Title: "${sc.title}" | ID: "${sc.id}" | URL: "${sc.youtubeUrl}" | Description:\n${sc.description || 'Interactive product reel'}\n`
);
}
});
// B. Match EcosystemOffering items (Only on explicit query or exact name match)
const isExplicitEcosystemQuery = promptLower.includes('ecosystem') || promptLower.includes('offering') || promptLower.includes('partner product') || promptLower.includes('explore more');
ecosystemOfferings.forEach((eo) => {
const fullText = (eo.name + ' ' + eo.tagline + ' ' + eo.description).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isExactNameMatch = promptLower.includes(eo.name.toLowerCase()) || eo.name.toLowerCase().includes(promptLower);
if (isExactNameMatch || (isExplicitEcosystemQuery && matchCount >= 2)) {
citationsMap.set(eo.id, {
assetId: eo.id,
title: eo.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[Ecosystem Offering] Name: "${eo.name}" | Type: "${eo.type}" | Tagline: "${eo.tagline}" | Website URL: "${eo.websiteUrl}" | Key Benefits: ${((eo.benefits as string[]) || []).join('; ')} | Description:\n${eo.description}\n`
);
}
});
// C. Direct Catalog Asset Title/Description/Taxonomy Search
const catalogAssets = await prisma.asset.findMany({
where: {
id: { in: accessibleAssetIds },
status: 'published',
},
include: {
assetGroups: { select: { name: true } },
verticals: { select: { name: true } },
techStacks: { select: { name: true } },
complianceStandards: { select: { name: true } },
}
});
const stopWords = new Set(['there', 'about', 'where', 'which', 'what', 'have', 'with', 'from', 'this', 'that', 'your', 'portal', 'asset', 'assets', 'product', 'item', 'these', 'those', 'please', 'explain', 'tell']);
const keyTerms = promptTerms.filter(t => !stopWords.has(t));
if (keyTerms.length > 0) {
catalogAssets.forEach(a => {
const fullText = (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.verticals || []).map(v => v.name).join(' ') + ' ' + (a.techStacks || []).map(t => t.name).join(' ')).toLowerCase();
let matchCount = 0;
keyTerms.forEach(kt => {
if (fullText.includes(kt)) matchCount++;
});
if (matchCount >= 1) {
const isRecommended = partnerGroup
? a.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
if (!citationsMap.has(a.id)) {
citationsMap.set(a.id, {
assetId: a.id,
title: a.title,
location: 'Catalog Asset Overview',
type: a.type,
isRecommended,
});
contextLines.push(
`[Direct Catalog Asset Match] Title: "${a.title}" | ID: "${a.id}" | Type: "${a.type}" | Description: "${a.description || ''}" | Problem: "${a.problemStatement || ''}" | Solution: "${a.solution || ''}"\n`
);
}
}
});
}
// D. Hybrid Vector Search (Only if key terms present)
const isPureNavPrompt = promptLower.includes('theme') || promptLower.includes('dark mode') || promptLower.includes('light mode') || promptLower.includes('how to change') || promptLower.includes('appearance');
if (!isPureNavPrompt && keyTerms.length > 0) {
const scoredChunks = embeddings.map(emb => {
let vectorScore = 0;
try {
const vec = JSON.parse(emb.vector) as number[];
vectorScore = promptVector.reduce((acc: number, val: number, i: number) => acc + val * (vec[i] || 0), 0);
} catch {
vectorScore = 0;
}
const chunkText = (emb.content + ' ' + emb.asset.title + ' ' + (emb.asset.description || '')).toLowerCase();
let keywordMatches = 0;
keyTerms.forEach(term => {
if (chunkText.includes(term)) keywordMatches += 1;
});
const hybridScore = vectorScore * 0.5 + (keywordMatches / Math.max(keyTerms.length, 1)) * 0.5;
return { chunk: emb, score: hybridScore, keywordMatches };
}).sort((a, b) => b.score - a.score);
const topAssetChunks = scoredChunks.filter(({ score, keywordMatches }) => score >= 0.35 && keywordMatches >= 1).slice(0, 3);
topAssetChunks.forEach(({ chunk }, idx) => {
const meta = (chunk.sourceMetadata as unknown as OKFMetadata) || {
assetId: chunk.assetId,
assetTitle: chunk.asset.title,
assetType: chunk.asset.type,
location: `Segment ${chunk.chunkIndex + 1}`,
};
const isRecommended = partnerGroup
? chunk.asset.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
if (!citationsMap.has(chunk.assetId)) {
citationsMap.set(chunk.assetId, {
assetId: chunk.assetId,
title: chunk.asset.title,
location: meta.location,
type: chunk.asset.type,
isRecommended,
});
}
contextLines.push(
`[Catalog Asset ${idx + 1}] Title: "${chunk.asset.title}" | ID: "${chunk.assetId}" | Location: "${meta.location}" | Details:\n${chunk.content}\n`
);
});
}
}
// D. Inject Assigned Legal Documents (NDA / MSA) if prompt asks about NDA / Legal
if (promptLower.includes('nda') || promptLower.includes('msa') || promptLower.includes('agreement') || promptLower.includes('legal') || promptLower.includes('contract')) {
const legals = await prisma.legalDocument.findMany();
legals.forEach((l: { type: string; version: string; content: string }, i: number) => {
contextLines.push(
`[Legal Document ${i + 1}] Type: "${l.type}" | Version: "${l.version}" | Content:\n${l.content.slice(0, 600)}...\n`
);
});
}
const contextBlock = contextLines.length > 0
? contextLines.join('\n---\n')
: 'No specific catalog or showcase snippets needed for this query.';
const portalGuideContext = isClient ? `
Exact Client Portal Layout & Step-by-Step Navigation Guide (${user.email}):
1. Legal Agreements & NDA/MSA Documents:
- Where to find it: Home Dashboard -> Click the "Legal Agreements" card (or navigate directly to /client/agreements).
- Note: There is NO "Legal Agreements" item in the left sidebar menu. Access it exclusively via the Home Dashboard card "Legal Agreements" or direct URL /client/agreements.
- Assigned NDA: ${user.assignedNda ? `Tech4Biz Standard NDA (v${user.assignedNda.version})` : 'Tech4Biz Standard NDA Agreement'} (Status: Active).
- Assigned MSA: ${user.assignedMsa ? `Tech4Biz Standard MSA (v${user.assignedMsa.version})` : 'Tech4Biz Standard MSA Agreement'} (Status: Active).
2. Asset Explorer (/client/assets):
- Where to find it: Left Sidebar menu -> Click "Asset Explorer" (or Home Dashboard -> "Asset Library" card).
- Features: Search shared catalog assets, filter by Taxonomy tags (Industry Verticals, Tech Stacks, Engagement Types, Compliance), view pitch decks, and request downloads.
3. Featured Content Showcase (/client/showcase):
- Where to find it: Left Sidebar menu -> Click "Featured Content".
- Features: Explore interactive video walk-throughs, YouTube/Instagram demo reels, and case studies (including Digital Twin, Diabetic Patient Time Travel, AuRa, Smart Basket, and AI reels).
4. Explore Ecosystem Offerings (/client/ecosystem):
- Where to find it: Left Sidebar menu -> Click "Explore More" (or Home Dashboard -> "Explore More" card).
- Features: Explore ecosystem products & partner integrations (CodeNuk, Cloudtopiaa).
5. Dark/Light Theme & Profile Settings:
- Theme Toggle: Click the Sun/Moon icon located in the bottom area of the left Sidebar under "Appearance".
- Profile Settings: Click "Profile Settings" in the left Sidebar menu to update password or default theme preferences.
` : `
Exact Admin Console Layout Guide for Administrator (${user.email}):
1. Partner Directory (/admin/partners): Left Sidebar -> "Partners".
2. Approvals Queue (/admin/approvals): Left Sidebar -> "Approvals Queue".
3. Legal Templates (/admin/legal): Left Sidebar -> "Legal Templates".
4. Catalog Management (/admin/assets): Left Sidebar -> "Manage Catalog".
5. Ecosystem Manager (/admin/ecosystem): Left Sidebar -> "Ecosystem Manager".
`;
const systemPrompt = `You are Tech4Biz AI Advisor Workbench, the official enterprise assistant for the Channel Partner Portal.
Role & Target Audience:
- User: ${user.email} (${isClient ? 'Client / Partner' : 'Portal Administrator'}).
OUTPUT FORMATTING REQUIREMENTS (CRITICAL FOR QUALITY & SECURITY):
1. **Never Output Database IDs or UUIDs**: Database IDs (UUIDs, hashes, etc.) are strictly confidential internal identifiers. You MUST NEVER output any ID or UUID in your response text to the user.
2. **Strict Guidelines for Suggested Links**:
- **Internal Portal Pages**: If referencing pages inside the portal, you must ONLY use these exact human-accessible relative links:
- Client Portal: \`/client/assets\` (Asset Explorer), \`/client/showcase\` (Featured Content), \`/client/ecosystem\` (Explore More), or \`/client/agreements\` (Legal Agreements).
- Admin Console: \`/admin/assets\` (Manage Catalog), \`/admin/showcase\` (Showcase Manager), \`/admin/ecosystem\` (Ecosystem Manager), or \`/admin/legal\` (Legal Templates).
- **CRITICAL**: Never append database IDs or UUIDs to these paths (e.g., do NOT link to \`/client/assets/123-abc\`). Doing so creates broken pages.
- **External Links**: You may suggest an external resource link (e.g. "[Visit Website](URL)") ONLY if the URL starts with \`http\` or \`https\` and is a public web link (not containing \`localhost\`, \`127.0.0.1\`, or \`/uploads/\`).
- **Fallback**: If no valid link exists, instruct the user to view it via the citation cards below the chat bubble using the **Quick Preview** button or locate it in the catalog.
3. **Multi-Turn Context Awareness**: Maintain full conversational memory. When asked for follow-ups or comparisons of previously mentioned assets, resolve references accurately.
4. **Never Output Concatenated Single-Line Tables**: EVERY Markdown table row MUST be separated by a real newline character (\\n). Never concatenate table rows like "| Col A | Col B | | :--- | :--- |".
5. **Structure Sections Clearly**: Use bold section titles (e.g. "### Summary", "### Key Differences", "### Features & Specifications").
6. **Use Bullet Points for Readability**: When detailing lists of features, target users, or tech stacks, use bulleted lists instead of long unformatted paragraphs.
${hasAttachedEntities ? `
CRITICAL RULES FOR WORKBENCH ATTACHED ENTITIES:
- THE USER HAS EXCLUSIVELY ATTACHED SPECIFIC ENTITIES TO INSPECT: ${attachedEntities?.map(e => `"${e.title}"`).join(', ')}.
- You MUST answer ONLY about the attached items listed under [WORKBENCH ATTACHED ASSET], [WORKBENCH ATTACHED FEATURED REEL], [WORKBENCH ATTACHED ECOSYSTEM OFFERING], or [WORKBENCH ATTACHED LEGAL AGREEMENT].
- Provide a clear, high-impact summary of what these attached items are, their core problem/solution, tech stack, and key features.
- Do NOT list, summarize, or invent any unattached showcase reels (like Digital Twin, Smart Basket, etc.) or unrelated catalog assets! Focus 100% EXCLUSIVELY on the attached items.
` : `
CRITICAL GROUNDING RULES (ZERO HALLUCINATION & STRICT KNOWLEDGE COMPLIANCE):
1. **Rely ONLY on Provided Knowledge Context**: Answer user questions 100% EXCLUSIVELY using the provided Knowledge Context. Do NOT use outside general knowledge or make ungrounded assumptions.
2. **No Invented Demos or Non-Working Links**: If the context does not explicitly list a live demo URL or document file link for an offering (such as CodeNuk, Cloudtopiaa, or Audittrax Labs), state clearly: "You can explore this offering under the 'Explore More' section (/client/ecosystem) or visit their website at [Website URL]." Do NOT invent dummy demo links, raw file paths, or non-working URLs.
3. **If Information is Missing**: If the provided Knowledge Context does not contain the answer, explicitly state: "I don't have detailed information on that in the portal database. Please check the Asset Explorer or contact your account administrator."
`}
Portal Navigation Guidelines:
- STIPULATION: NEVER tell a Client user to look for "Legal Agreements" in the left sidebar. State clearly: "Click the 'Legal Agreements' card on your Home Dashboard or go to /client/agreements".
- STIPULATION: NEVER mention admin console routes (/admin, Partner Directory, Legal Templates) when talking to a Client user.`;
// 0. Fetch Past Conversation History for Session Context & Follow-up Resolution
let pastMessages: { role: string; content: string }[] = [];
let activeSessionId = sessionId;
if (!activeSessionId) {
const session = await prisma.chatSession.create({
data: { userId }
});
activeSessionId = session.id;
} else {
const dbPast = await prisma.chatMessage.findMany({
where: { sessionId: activeSessionId },
orderBy: { createdAt: 'asc' },
take: 12,
});
pastMessages = dbPast.map(m => ({
role: m.sender === 'USER' ? 'user' : 'assistant',
content: m.content
}));
// Extract citations / asset references from recent ASSISTANT messages to handle follow-up queries like "provide more details on this asset"
const recentBotMsgs = dbPast.filter(m => m.sender === 'ASSISTANT' && m.citations);
for (const botMsg of recentBotMsgs) {
const cites = (botMsg.citations as unknown as CitationItem[]) || [];
for (const cite of cites) {
if (cite.assetId && !citationsMap.has(cite.assetId)) {
const dbAsset = await prisma.asset.findUnique({
where: { id: cite.assetId },
include: {
verticals: true,
techStacks: true,
complianceStandards: true,
}
});
if (dbAsset) {
citationsMap.set(dbAsset.id, {
assetId: dbAsset.id,
title: dbAsset.title,
location: 'Previously Discussed Asset',
type: dbAsset.type,
isRecommended: false,
});
contextLines.push(
`[PREVIOUSLY DISCUSSED ASSET IN CONVERSATION] Title: "${dbAsset.title}" | Type: "${dbAsset.type}" | Link/URL: "${dbAsset.url}" | Category: "${dbAsset.categoryId || 'General'}" | Description: "${dbAsset.description || 'N/A'}" | Problem Statement: "${dbAsset.problemStatement || 'N/A'}" | Solution Overview: "${dbAsset.solution || 'N/A'}" | Industry Verticals: "${(dbAsset.verticals || []).map(v => v.name).join(', ')}" | Tech Stack: "${(dbAsset.techStacks || []).map(t => t.name).join(', ')}"\n`
);
}
}
}
}
}
// Save current User prompt first to maintain chronological integrity
await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'USER',
content: prompt,
}
});
const messages = [
{ role: 'system', content: systemPrompt },
...pastMessages,
{ role: 'user', content: `Platform Navigation Guide:\n${portalGuideContext}\n\nKnowledge Context:\n${contextBlock}\n\nUser Question: ${prompt}` }
];
// 6. Call DeepSeek LLM Gateway API
let assistantReply = '';
try {
const response = await axios.post(`${this.gatewayUrl}/chat/completions`, {
model: this.modelId,
messages,
temperature: 0.2,
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
assistantReply = response.data.choices[0]?.message?.content || 'No response generated from AI Engine.';
} catch (err: any) {
console.error('DeepSeek Gateway Error:', err?.response?.data || err.message);
assistantReply = 'Here is the requested information from your shared catalog:\n\n' + contextBlock;
}
// Strict Citation Filtering: Only include Verified Knowledge Source citations if user explicitly attached entities or asked for asset/resource recommendations
const isGeneralHelpQuery = promptLower.includes('password') ||
promptLower.includes('theme') ||
promptLower.includes('appearance') ||
promptLower.includes('profile') ||
promptLower.includes('how to log') ||
promptLower.includes('how to sign') ||
promptLower.includes('help');
const hasAssetKeywords = promptLower.includes('asset') ||
promptLower.includes('recommend') ||
promptLower.includes('find') ||
promptLower.includes('search') ||
promptLower.includes('show') ||
promptLower.includes('list') ||
promptLower.includes('what are') ||
promptLower.includes('is there') ||
promptLower.includes('are there') ||
promptLower.includes('showcase') ||
promptLower.includes('agreement') ||
promptLower.includes('legal') ||
promptLower.includes('document') ||
promptLower.includes('video') ||
promptLower.includes('reel') ||
promptLower.includes('offering') ||
promptLower.includes('which are') ||
promptLower.includes('tell me more');
const finalCitations = (hasAttachedEntities || (hasAssetKeywords && !isGeneralHelpQuery))
? Array.from(citationsMap.values())
: [];
const botMessage = await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'ASSISTANT',
content: assistantReply,
citations: finalCitations as any,
}
});
return {
sessionId: activeSessionId,
message: botMessage,
citations: finalCitations,
};
}
/**
* Auto-indexes active catalog assets into AssetEmbedding for instant knowledge retrieval.
*/
public async autoIndexCatalog(assetIds: string[]) {
const assets = await prisma.asset.findMany({
where: {
id: { in: assetIds },
includeInKnowledgeBase: true,
}
});
for (const asset of assets) {
await this.ingestAssetKnowledge(asset.id);
}
}
/**
* Ingest or re-index an asset's text into vector embeddings.
*/
public async ingestAssetKnowledge(assetId: string) {
const asset = await prisma.asset.findUnique({
where: { id: assetId }
});
if (!asset) return;
await prisma.assetEmbedding.deleteMany({
where: { assetId }
});
if (!asset.includeInKnowledgeBase) return;
const okfChunks = await this.extractionService.extractOKFChunks(asset);
for (const chunk of okfChunks) {
const vector = this.extractionService.generateEmbedding(chunk.content);
await prisma.assetEmbedding.create({
data: {
assetId,
chunkIndex: chunk.chunkIndex,
chunkType: chunk.chunkType,
sourceMetadata: chunk.sourceMetadata as any,
content: chunk.content,
vector,
}
});
}
}
}

View File

@ -1,310 +0,0 @@
import fs from 'fs';
import path from 'path';
const _pdfParse = require('pdf-parse');
const pdfParse = _pdfParse.PDFParse || _pdfParse.default || _pdfParse;
import mammoth from 'mammoth';
import * as XLSX from 'xlsx';
import * as cheerio from 'cheerio';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import { ScraperService } from './scraper.service';
export interface OKFMetadata {
assetId: string;
assetTitle: string;
assetType: string;
location: string; // e.g. "Page 3", "Slide 5", "Sheet: Financials", "Transcript", "Section: Architecture"
okfCategory?: string;
isRecommended?: boolean;
}
export interface OKFChunk {
chunkIndex: number;
chunkType: 'PAGE' | 'SLIDE' | 'SHEET' | 'TRANSCRIPT' | 'TEXT';
content: string;
sourceMetadata: OKFMetadata;
}
export class ExtractionService {
private scraperService = new ScraperService();
/**
* Simple, fast deterministic embedding generator for local RAG vector search.
* Generates a 64-dimensional float vector normalized for cosine similarity.
*/
public generateEmbedding(text: string): string {
const dim = 64;
const vector = new Array(dim).fill(0);
const cleaned = text.toLowerCase().replace(/[^\w\s]/g, '');
const words = cleaned.split(/\s+/).filter(Boolean);
for (let i = 0; i < words.length; i++) {
const word = words[i];
for (let j = 0; j < word.length; j++) {
const charCode = word.charCodeAt(j);
const idx = (charCode + j * 7 + i * 3) % dim;
vector[idx] += 1;
}
}
// L2 Normalize
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0)) || 1;
const normalized = vector.map(v => Number((v / magnitude).toFixed(6)));
return JSON.stringify(normalized);
}
/**
* Extract text chunks from an asset binary or URL using the Open Knowledge Framework (OKF) standard format.
*/
public async extractOKFChunks(asset: {
id: string;
title: string;
type: string;
url: string;
problemStatement?: string | null;
solution?: string | null;
description?: string | null;
}): Promise<OKFChunk[]> {
const chunks: OKFChunk[] = [];
let chunkIndex = 0;
// Base Primary Metadata Chunk (Guarantees 100% indexing for ALL assets including URLs & Documents)
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Asset Title: "${asset.title}". Type: "${asset.type}". Description: "${asset.description || ''}". Problem: "${asset.problemStatement || ''}". Solution: "${asset.solution || ''}".`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Catalog Overview & Metadata',
},
});
// 1. Ingest Problem Statement & Solution metadata if present
if (asset.problemStatement) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Problem Statement for ${asset.title}: ${asset.problemStatement}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Problem Statement',
},
});
}
if (asset.solution) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Solution Overview for ${asset.title}: ${asset.solution}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Proposed Solution',
},
});
}
// 2. URL Assets / YouTube / Web scraper handling
if (asset.type === 'url' || asset.type === 'case_study' || asset.url.startsWith('http')) {
try {
const scraped = await this.scraperService.scrapeCaseStudy(asset.url);
const fullContent = `${scraped.title}. ${scraped.problemStatement || ''} ${scraped.solution || ''}`;
// Split into 500-token chunks
const subChunks = this.splitText(fullContent, 500);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TRANSCRIPT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Web Link Content: Part ${i + 1}`,
},
});
});
} catch {
if (asset.description) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `${asset.title}: ${asset.description}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'URL Asset Metadata',
},
});
}
}
return chunks;
}
// 3. Binary S3 Assets (PDF, Word, Excel, PPTX, Text)
if (asset.url.startsWith('/uploads/')) {
const fileKey = asset.url.replace('/uploads/', '');
let buffer: Buffer;
try {
const response = await s3Client.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
}));
const byteArray = await response.Body?.transformToByteArray();
if (!byteArray) return chunks;
buffer = Buffer.from(byteArray);
} catch (err) {
console.error(`Failed to fetch S3 object ${fileKey} for extraction:`, err);
return chunks;
}
const ext = path.extname(fileKey).toLowerCase();
// A. PDF Files
if (ext === '.pdf' || asset.type.includes('pdf')) {
try {
let pdfText = '';
try {
const parser = new pdfParse({ data: buffer });
const res = await parser.getText();
pdfText = typeof res === 'string' ? res : res?.text || '';
} catch (e1) {
try {
const res = await pdfParse(buffer);
pdfText = typeof res === 'string' ? res : res?.text || '';
} catch (e2) {}
}
if (pdfText) {
const subChunks = this.splitText(pdfText, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'PAGE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PDF Document: Page ${i + 1}`,
},
});
});
}
} catch (e) { console.error('PDF extraction error:', e); }
}
// B. Word Files (.docx, .doc)
else if (ext === '.docx' || ext === '.doc' || asset.type.includes('word')) {
try {
const docResult = await mammoth.extractRawText({ buffer });
const subChunks = this.splitText(docResult.value, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Word Document: Section ${i + 1}`,
},
});
});
} catch (e) { console.error('Docx extraction error:', e); }
}
// C. Excel & CSV Files (.xlsx, .xls, .csv)
else if (ext === '.xlsx' || ext === '.xls' || ext === '.csv' || asset.type.includes('spreadsheet') || asset.type.includes('csv')) {
try {
const workbook = XLSX.read(buffer, { type: 'buffer' });
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const csvText = XLSX.utils.sheet_to_csv(sheet);
if (csvText && csvText.trim()) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SHEET',
content: `Sheet [${sheetName}] Data:\n${csvText.slice(0, 1500)}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Spreadsheet Sheet: ${sheetName}`,
},
});
}
});
} catch (e) { console.error('Excel extraction error:', e); }
}
// D. PowerPoint Presentations (.pptx, .ppt)
else if (ext === '.pptx' || ext === '.ppt' || asset.type.includes('presentation')) {
// Simple text extraction from raw slide XML/strings
const rawString = buffer.toString('utf-8').replace(/[^\x20-\x7E]/g, ' ');
const subChunks = this.splitText(rawString, 600);
subChunks.forEach((text, i) => {
if (text.length > 50) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SLIDE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PowerPoint Presentation: Slide Section ${i + 1}`,
},
});
}
});
}
// E. Plain Text / Markdown
else {
const textContent = buffer.toString('utf-8');
const subChunks = this.splitText(textContent, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Document Text: Segment ${i + 1}`,
},
});
});
}
}
return chunks;
}
private splitText(text: string, maxLen: number): string[] {
const cleaned = text.replace(/\s+/g, ' ').trim();
if (!cleaned) return [];
const words = cleaned.split(' ');
const chunks: string[] = [];
let current = '';
for (const word of words) {
if ((current + ' ' + word).length > maxLen) {
if (current) chunks.push(current.trim());
current = word;
} else {
current += (current ? ' ' : '') + word;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
}

View File

@ -25,14 +25,6 @@ export class MailService {
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173'; const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
const inviteUrl = `${origin}/invite?token=${inviteToken}`; const inviteUrl = `${origin}/invite?token=${inviteToken}`;
const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true' && process.env.NODE_ENV === 'production';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked real email dispatch to real user/client: ${email}`);
console.log(`[DEV EMAIL SAFEGUARD] Mock Invite URL: ${inviteUrl}`);
return { messageId: 'mock-dev-safeguard-id' };
}
const mailOptions = { const mailOptions = {
from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" <noreply@tech4biz.com>'), from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" <noreply@tech4biz.com>'),
to: email, to: email,
@ -74,33 +66,4 @@ export class MailService {
throw err; throw err;
} }
} }
public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) {
if (!options.recipients || options.recipients.length === 0) return;
const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true' && process.env.NODE_ENV === 'production';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked announcement email to ${options.recipients.length} recipients (Development mode safety guard). Subject: "${options.subject}"`);
return;
}
const mailOptions = {
from: process.env.SMTP_FROM || '"Tech4Biz Portal" <noreply@tech4biz.com>',
to: options.recipients.join(', '),
subject: options.subject,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #ffffff;">
<h2 style="color: #0f172a; margin-top: 0;">${options.subject}</h2>
<div style="font-size: 14px; color: #334155; line-height: 1.6; white-space: pre-wrap; background: #f8fafc; padding: 16px; border-radius: 6px; border: 1px solid #e2e8f0;">${options.messageBody}</div>
<p style="font-size: 12px; color: #64748b; margin-top: 24px;">Sent via Tech4Biz Channel Partner Platform</p>
</div>
`
};
try {
await this.transporter.sendMail(mailOptions);
console.log(`[SMTP] Announcement email sent to ${options.recipients.length} recipients.`);
} catch (err) {
console.error('[SMTP ERROR] Failed to send announcement email:', err);
}
}
} }

View File

@ -1,212 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './db';
export async function seedFourGroupTaxonomy() {
console.log('[Taxonomy-Seed] Starting taxonomy re-seeding with exact user specifications...');
await prisma.vertical.deleteMany();
await prisma.techStack.deleteMany();
await prisma.engagementType.deleteMany();
await prisma.complianceStandard.deleteMany();
console.log('[Taxonomy-Seed] Cleared existing taxonomy tables.');
// 1. Group 1: Industry Verticals (11 Entries)
const verticalsData = [
{ name: 'Cybersecurity & OT Security', slug: 'cybersecurity-ot-security', icon: 'Shield', description: 'ICS/SCADA protection, threat detection, and OT security architecture.', color: '#ef4444', orderIndex: 1 },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Heart', description: 'Patient monitoring, clinical workflows, and pharma tech.', color: '#10b981', orderIndex: 2 },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'CreditCard', description: 'Core banking systems, fraud detection, and fintech platforms.', color: '#3b82f6', orderIndex: 3 },
{ name: 'Insurance', slug: 'insurance', icon: 'ShieldCheck', description: 'InsurTech systems, claim automation, and actuarial analytics.', color: '#0284c7', orderIndex: 4 },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', description: 'Grid monitoring, renewable management, and infrastructure tech.', color: '#06b6d4', orderIndex: 5 },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Sprout', description: 'AgriTech telemetry, precision farming, and supply analytics.', color: '#84cc16', orderIndex: 6 },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', description: 'EdTech platforms, AI tutoring, and campus management.', color: '#f59e0b', orderIndex: 7 },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Cpu', description: 'Predictive maintenance, IIoT telemetry, and smart factory tech.', color: '#8b5cf6', orderIndex: 8 },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', description: 'Connected vehicles, EV telemetry, and autonomous systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', description: 'Smart basket automation, inventory AI, and logistics.', color: '#f97316', orderIndex: 10 },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', description: 'Distributed ledgers, smart contracts, and Web3 security.', color: '#6366f1', orderIndex: 11 },
];
const createdVerticals: Record<string, string> = {};
for (const item of verticalsData) {
const v = await prisma.vertical.create({ data: item });
createdVerticals[item.name] = v.id;
}
// 2. Group 2: Technology Stack (21 Entries across 4 Categories)
const techStacksData = [
// Languages/Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', icon: 'Code', description: 'Enterprise backend services and Spring ecosystem.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', icon: 'Server', description: 'Event-driven JavaScript/TypeScript backend runtimes.', color: '#10b981', orderIndex: 2 },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', icon: 'FileCode', description: 'Data science, AI models, and microservices.', color: '#f59e0b', orderIndex: 3 },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', icon: 'Layout', description: 'Modern web component UIs and frontend state management.', color: '#06b6d4', orderIndex: 4 },
{ name: 'Go', slug: 'golang', category: 'Languages & Frameworks', icon: 'Cpu', description: 'High-performance cloud-native microservices.', color: '#0284c7', orderIndex: 5 },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', icon: 'Layers', description: 'C# enterprise applications and Microsoft ecosystem.', color: '#6366f1', orderIndex: 6 },
// AI/ML
{ name: 'AI & ML', slug: 'ai-ml', category: 'AI & ML', icon: 'Sparkles', description: 'Core artificial intelligence and machine learning models.', color: '#8b5cf6', orderIndex: 7 },
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', icon: 'Bot', description: 'Large language models, multi-agent frameworks, and reasoning engines.', color: '#a855f7', orderIndex: 8 },
{ name: 'RAG', slug: 'rag', category: 'AI & ML', icon: 'Database', description: 'Retrieval-Augmented Generation and vector search systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', icon: 'Camera', description: 'Real-time video analytics and optical recognition.', color: '#f43f5e', orderIndex: 10 },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', icon: 'GitBranch', description: 'MLOps, model retraining, and feature stores.', color: '#d946ef', orderIndex: 11 },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', icon: 'Eye', description: 'Synthetic media verification and anti-spoofing.', color: '#ef4444', orderIndex: 12 },
// Data/Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', icon: 'Database', description: 'Relational database with JSONB and vector capabilities.', color: '#3b82f6', orderIndex: 13 },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', icon: 'Clock', description: 'Durable workflow execution and saga orchestrations.', color: '#10b981', orderIndex: 14 },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', icon: 'Activity', description: 'Distributed event streaming and message pub/sub.', color: '#f59e0b', orderIndex: 15 },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', icon: 'Zap', description: 'Asynchronous event architecture and CQRS patterns.', color: '#06b6d4', orderIndex: 16 },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', icon: 'Grid', description: 'Decoupled service APIs and domain-driven design.', color: '#6366f1', orderIndex: 17 },
// Cloud/Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', icon: 'Cloud', description: 'Amazon Web Services cloud infrastructure.', color: '#f97316', orderIndex: 18 },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', icon: 'Lock', description: 'Sovereign cloud hosting and air-gapped on-premise deployments.', color: '#64748b', orderIndex: 19 },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', icon: 'Box', description: 'K8s container orchestration and mesh networking.', color: '#0284c7', orderIndex: 20 },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', icon: 'Server', description: 'Infrastructure-as-a-Service and virtualized bare metal.', color: '#475569', orderIndex: 21 },
];
const createdTechStacks: Record<string, string> = {};
for (const item of techStacksData) {
const ts = await prisma.techStack.create({ data: item });
createdTechStacks[item.name] = ts.id;
}
// 3. Group 3: Engagement Type (4 Entries)
const engagementTypesData = [
{ name: 'Build', slug: 'build', icon: 'Wrench', description: 'Greenfield product engineering and 0-to-1 development.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Rescue', slug: 'rescue', icon: 'LifeBuoy', description: 'Turnaround engineering, legacy modernization, and critical fixes.', color: '#ef4444', orderIndex: 2 },
{ name: 'Scale', slug: 'scale', icon: 'TrendingUp', description: 'Performance optimization, architecture scaling, and throughput expansion.', color: '#10b981', orderIndex: 3 },
{ name: 'Due Diligence', slug: 'due-diligence', icon: 'FileSearch', description: 'Technical audits, code reviews, and M&A architecture assessments.', color: '#f59e0b', orderIndex: 4 },
];
const createdEngagementTypes: Record<string, string> = {};
for (const item of engagementTypesData) {
const et = await prisma.engagementType.create({ data: item });
createdEngagementTypes[item.name] = et.id;
}
// 4. Group 4: Compliance / Regulatory (5 Entries)
const complianceStandardsData = [
{ name: 'HIPAA', slug: 'hipaa', icon: 'Activity', description: 'Health Insurance Portability and Accountability Act.', color: '#ec4899', orderIndex: 1 },
{ name: 'GxP', slug: 'gxp', icon: 'ShieldCheck', description: 'Good Practice quality guidelines for pharma and life sciences.', color: '#10b981', orderIndex: 2 },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', icon: 'Building', description: 'APRA Operational Risk Management standard for banking.', color: '#3b82f6', orderIndex: 3 },
{ name: 'SOC 2', slug: 'soc-2', icon: 'FileCheck', description: 'SOC 2 security, availability, and confidentiality controls.', color: '#06b6d4', orderIndex: 4 },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', icon: 'Lock', description: 'EU General Data Protection Regulation and data sovereignty.', color: '#8b5cf6', orderIndex: 5 },
];
const createdCompliance: Record<string, string> = {};
for (const item of complianceStandardsData) {
const cs = await prisma.complianceStandard.create({ data: item });
createdCompliance[item.name] = cs.id;
}
console.log('[Taxonomy-Seed] Successfully seeded 11 Verticals, 21 Tech Stacks, 4 Engagement Types, and 5 Compliance Standards.');
// 5. Re-map Catalog Assets to the Exact Taxonomy Entries
const assets = await prisma.asset.findMany();
console.log(`[Taxonomy-Seed] Mapping exact taxonomy relations for ${assets.length} catalog assets...`);
let updatedCount = 0;
for (const asset of assets) {
const text = (asset.title + ' ' + (asset.description || '') + ' ' + (asset.tags || []).join(' ')).toLowerCase();
const targetVerticals: string[] = [];
const targetTechs: string[] = [];
const targetEngagements: string[] = [];
const targetCompliance: string[] = [];
// Verticals mapping
if (text.includes('cyber') || text.includes('security') || text.includes('scada') || text.includes('ot security')) {
if (createdVerticals['Cybersecurity & OT Security']) targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (text.includes('health') || text.includes('patient') || text.includes('medical') || text.includes('pharma') || text.includes('diabetic')) {
if (createdVerticals['Healthcare & Pharma']) targetVerticals.push(createdVerticals['Healthcare & Pharma']);
}
if (text.includes('bank') || text.includes('finance') || text.includes('fintech') || text.includes('payment')) {
if (createdVerticals['Finance & Banking']) targetVerticals.push(createdVerticals['Finance & Banking']);
}
if (text.includes('insurance') || text.includes('claim')) {
if (createdVerticals['Insurance']) targetVerticals.push(createdVerticals['Insurance']);
}
if (text.includes('energy') || text.includes('grid') || text.includes('metering') || text.includes('utility') || text.includes('water')) {
if (createdVerticals['Energy & Utilities']) targetVerticals.push(createdVerticals['Energy & Utilities']);
}
if (text.includes('agri') || text.includes('farm') || text.includes('crop')) {
if (createdVerticals['Agriculture']) targetVerticals.push(createdVerticals['Agriculture']);
}
if (text.includes('student') || text.includes('education') || text.includes('textbook') || text.includes('school') || text.includes('plagiarism')) {
if (createdVerticals['Education']) targetVerticals.push(createdVerticals['Education']);
}
if (text.includes('manufactur') || text.includes('iot') || text.includes('sensor') || text.includes('factory')) {
if (createdVerticals['Manufacturing & IoT']) targetVerticals.push(createdVerticals['Manufacturing & IoT']);
}
if (text.includes('auto') || text.includes('vehicle') || text.includes('car') || text.includes('ev ')) {
if (createdVerticals['Automotive']) targetVerticals.push(createdVerticals['Automotive']);
}
if (text.includes('retail') || text.includes('basket') || text.includes('store') || text.includes('supply')) {
if (createdVerticals['Retail & Supply Chain']) targetVerticals.push(createdVerticals['Retail & Supply Chain']);
}
if (text.includes('blockchain') || text.includes('ledger') || text.includes('web3')) {
if (createdVerticals['Blockchain']) targetVerticals.push(createdVerticals['Blockchain']);
}
// Tech Stacks mapping
if (text.includes('ai') || text.includes('ml') || text.includes('model') || text.includes('predictive') || text.includes('chatbot')) {
if (createdTechStacks['AI & ML']) targetTechs.push(createdTechStacks['AI & ML']);
}
if (text.includes('llm') || text.includes('agent') || text.includes('gpt') || text.includes('deepseek')) {
if (createdTechStacks['LLM / Agentic']) targetTechs.push(createdTechStacks['LLM / Agentic']);
}
if (text.includes('rag') || text.includes('vector') || text.includes('retrieval')) {
if (createdTechStacks['RAG']) targetTechs.push(createdTechStacks['RAG']);
}
if (text.includes('vision') || text.includes('camera') || text.includes('image')) {
if (createdTechStacks['Computer Vision']) targetTechs.push(createdTechStacks['Computer Vision']);
}
if (text.includes('postgres') || text.includes('db') || text.includes('sql')) {
if (createdTechStacks['PostgreSQL']) targetTechs.push(createdTechStacks['PostgreSQL']);
}
if (text.includes('aws') || text.includes('cloud') || text.includes('server')) {
if (createdTechStacks['AWS']) targetTechs.push(createdTechStacks['AWS']);
}
// Default Fallbacks
if (targetVerticals.length === 0 && createdVerticals['Cybersecurity & OT Security']) {
targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (targetTechs.length === 0 && createdTechStacks['AI & ML']) {
targetTechs.push(createdTechStacks['AI & ML']);
}
if (createdEngagementTypes['Build']) {
targetEngagements.push(createdEngagementTypes['Build']);
}
if (createdCompliance['SOC 2']) {
targetCompliance.push(createdCompliance['SOC 2']);
}
await prisma.asset.update({
where: { id: asset.id },
data: {
verticals: { connect: targetVerticals.map(id => ({ id })) },
techStacks: { connect: targetTechs.map(id => ({ id })) },
engagementTypes: { connect: targetEngagements.map(id => ({ id })) },
complianceStandards: { connect: targetCompliance.map(id => ({ id })) },
}
});
updatedCount++;
}
console.log(`[Taxonomy-Seed] Successfully mapped exact taxonomy relations for ${updatedCount} assets.`);
}
if (require.main === module || (process.argv[1] && process.argv[1].includes('seed-taxonomy'))) {
seedFourGroupTaxonomy()
.then(() => process.exit(0))
.catch(err => {
console.error('[Taxonomy-Seed] Failed:', err);
process.exit(1);
});
}

View File

@ -5,9 +5,6 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech4Biz Client & Admin Portal</title> <title>Tech4Biz Client & Admin Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@ import {
Globe Globe
} from "lucide-react"; } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { ChatDrawer } from "../../components/ui/ChatDrawer";
export const AdminLayout: React.FC = () => { export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -284,7 +283,6 @@ export const AdminLayout: React.FC = () => {
</div> </div>
</footer> </footer>
</main> </main>
<ChatDrawer />
</div> </div>
); );
}; };

View File

@ -4,7 +4,6 @@ import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth'; import { useAuthStore } from '../../hooks/use-auth';
import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react'; import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChatDrawer } from '../../components/ui/ChatDrawer';
export const ClientLayout: React.FC = () => { export const ClientLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -395,8 +394,6 @@ export const ClientLayout: React.FC = () => {
</div> </div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* AI Assistant Chat Drawer */}
<ChatDrawer />
</div> </div>
); );
}; };

View File

@ -16,27 +16,27 @@ export const PageLayout: React.FC<PageLayoutProps> = ({
className = '', className = '',
}) => { }) => {
return ( return (
<div className={`flex-1 flex flex-col min-h-0 h-full w-full overflow-hidden ${className}`}> <div className={`flex flex-col h-[calc(100vh-140px)] md:h-[calc(100vh-160px)] w-full overflow-hidden ${className}`}>
{/* Page Header (Fixed) */} {/* Page Header (Fixed) */}
<div className="shrink-0 mb-3 sm:mb-4"> <div className="shrink-0 mb-4">
{header} {header}
</div> </div>
{/* Toolbar (Fixed) */} {/* Toolbar (Fixed) */}
{toolbar && ( {toolbar && (
<div className="shrink-0 mb-3 sm:mb-4"> <div className="shrink-0 mb-4">
{toolbar} {toolbar}
</div> </div>
)} )}
{/* Scrollable Content Area */} {/* Scrollable Content Area */}
<div className="flex-1 min-h-0 overflow-y-auto bg-ink-0 border border-ink-200 rounded-2xl shadow-sm relative flex flex-col scrollbar-thin"> <div className="flex-1 min-h-0 overflow-y-auto bg-ink-0 border border-ink-200 rounded-xl shadow-sm relative flex flex-col scrollbar-thin">
{children} {children}
</div> </div>
{/* Page-level Footer/Pagination (Fixed) */} {/* Page-level Footer/Pagination (Fixed) */}
{footer && ( {footer && (
<div className="shrink-0 mt-3 sm:mt-4"> <div className="shrink-0 mt-4">
{footer} {footer}
</div> </div>
)} )}

View File

@ -1,687 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, X, Send, Bot, User as UserIcon, FileText, Minimize2, Maximize2, RefreshCw, Compass, Eye, Plus, History, MessageSquare } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { useAuthStore } from '../../hooks/use-auth';
import { AssetViewerModal } from '../../features/assets/components/AssetViewerModal';
import MarkdownViewer from './MarkdownViewer';
import type { Asset } from '../../types/assets';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export interface ChatMessage {
id?: string;
sender: 'USER' | 'ASSISTANT';
content: string;
citations?: CitationItem[];
createdAt?: string;
}
export interface ChatSessionItem {
id: string;
createdAt: string;
updatedAt: string;
messages?: ChatMessage[];
}
export interface AttachedEntity {
id: string;
title: string;
type: string;
entityKind: 'ASSET' | 'SHOWCASE' | 'ECOSYSTEM' | 'LEGAL';
url?: string;
description?: string;
problemStatement?: string;
solution?: string;
thumbnailUrl?: string;
tags?: string[];
}
export const ChatDrawer: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [drawerSize, setDrawerSize] = useState<'standard' | 'wide' | 'maximized'>('standard');
const [showHistory, setShowHistory] = useState(false);
const [prompt, setPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [pastSessions, setPastSessions] = useState<ChatSessionItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [attachedEntities, setAttachedEntities] = useState<AttachedEntity[]>([]);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
sender: 'ASSISTANT',
content: 'Hello! I am your **Tech4Biz AI Advisor Workbench**. **Drag and drop any asset, case study reel, ecosystem offering** here to inspect, summarize, and receive instant role-tailored explanations!',
}
]);
const [activePreviewAsset, setActivePreviewAsset] = useState<Asset | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [hasActiveOverlay, setHasActiveOverlay] = useState(false);
useEffect(() => {
const checkOverlays = () => {
const overlays = document.querySelectorAll('.fixed.inset-0.z-50, .fixed.inset-y-0.right-0');
setHasActiveOverlay(overlays.length > 0);
};
checkOverlays();
const interval = setInterval(checkOverlays, 300);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (isOpen) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isOpen]);
// Listen for custom attach event from card button clicks
useEffect(() => {
const handleAttachEvent = (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail) {
const entity = customEvent.detail as AttachedEntity;
setIsOpen(true);
setIsMinimized(false);
setAttachedEntities(prev => {
if (prev.some(item => item.id === entity.id)) return prev;
return [...prev, entity];
});
}
};
window.addEventListener('attach-ai-entity', handleAttachEvent);
return () => window.removeEventListener('attach-ai-entity', handleAttachEvent);
}, []);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
const entity = JSON.parse(jsonStr) as AttachedEntity;
if (entity.id && entity.title) {
setIsOpen(true);
setIsMinimized(false);
setAttachedEntities(prev => {
if (prev.some(item => item.id === entity.id)) return prev;
return [...prev, entity];
});
}
} catch (err) {
console.error('Failed to parse dropped entity payload', err);
}
}
};
// Load user session history when history drawer is opened
const fetchSessionHistory = async () => {
setLoadingHistory(true);
try {
const res = await axiosInstance.get('/chat/history');
setPastSessions(res.data || []);
} catch (err) {
console.error('Failed to fetch chat history:', err);
} finally {
setLoadingHistory(false);
}
};
const handleToggleHistory = () => {
if (!showHistory) {
fetchSessionHistory();
}
setShowHistory(!showHistory);
};
const handleStartNewChat = async () => {
setLoading(true);
try {
const res = await axiosInstance.post('/chat/sessions');
setSessionId(res.data.sessionId);
setMessages([
{
sender: 'ASSISTANT',
content: 'Started a new session! **Drag & Drop** any catalog asset, case study, or ecosystem offering below for instant AI analysis.',
}
]);
setShowHistory(false);
setAttachedEntities([]);
} catch (err) {
console.error('Failed to create new session', err);
} finally {
setLoading(false);
}
};
const handleLoadSession = async (sessId: string) => {
setLoading(true);
try {
const res = await axiosInstance.get(`/chat/sessions/${sessId}`);
setSessionId(sessId);
const loadedMessages = (res.data.messages || []).map((m: any) => ({
id: m.id,
sender: m.sender,
content: m.content,
citations: m.citations || [],
createdAt: m.createdAt,
}));
setMessages(loadedMessages.length > 0 ? loadedMessages : [
{
sender: 'ASSISTANT',
content: 'Loaded past session. Ask your follow up questions below!',
}
]);
setShowHistory(false);
} catch (err) {
console.error('Failed to load session', err);
} finally {
setLoading(false);
}
};
const handleSend = async (customPrompt?: string) => {
const textToSend = customPrompt || prompt;
if ((!textToSend.trim() && attachedEntities.length === 0) || loading) return;
const effectiveText = textToSend.trim() || `Explain and analyze the attached ${attachedEntities.length} dropped entity/entities in detail.`;
const userMsg: ChatMessage = {
sender: 'USER',
content: effectiveText + (attachedEntities.length > 0 ? `\n\n📌 *Attached Items:* ${attachedEntities.map(e => e.title).join(', ')}` : ''),
createdAt: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
if (!customPrompt) setPrompt('');
const currentAttached = [...attachedEntities];
setAttachedEntities([]);
setLoading(true);
try {
const response = await axiosInstance.post('/chat/query', {
prompt: effectiveText,
sessionId,
attachedEntities: currentAttached,
});
const { sessionId: newSessionId, message } = response.data;
setSessionId(newSessionId);
setMessages(prev => [...prev, {
id: message.id,
sender: 'ASSISTANT',
content: message.content,
citations: message.citations || [],
createdAt: message.createdAt,
}]);
} catch (err: any) {
console.error('Chat API Error:', err);
setMessages(prev => [...prev, {
sender: 'ASSISTANT',
content: 'Sorry, I encountered an issue connecting to the AI Gateway. Please try again.',
}]);
} finally {
setLoading(false);
}
};
const openAssetPreview = async (cite: CitationItem) => {
try {
if (cite.location === 'Featured Content Showcase') {
const showcaseRes = await axiosInstance.get('/showcase');
const showcaseItem = showcaseRes.data.find((item: any) => item.id === cite.assetId || item.title === cite.title);
if (showcaseItem) {
setActivePreviewAsset({
id: showcaseItem.id,
title: showcaseItem.title,
type: 'case_study',
url: showcaseItem.youtubeUrl || showcaseItem.mediaUrl || 'https://www.youtube.com/watch?v=svJDGYlQLYw',
description: showcaseItem.description || `Verified Showcase Reel: ${showcaseItem.title}`,
isDownloadable: false,
createdAt: showcaseItem.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
return;
}
}
const res = await axiosInstance.get(`/assets/${cite.assetId}`);
setActivePreviewAsset(res.data);
} catch (err) {
console.error('Failed to fetch asset from API, constructing fallback preview:', err);
let videoUrl = '';
if (cite.title.toLowerCase().includes('digital twin') || cite.title.toLowerCase().includes('digitaltwin')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
} else if (cite.title.toLowerCase().includes('surveillance')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
}
setActivePreviewAsset({
id: cite.assetId,
title: cite.title,
type: cite.type || 'case_study',
url: videoUrl,
description: `Verified Document Source: ${cite.title} (${cite.location})`,
isDownloadable: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
}
};
const handleGoToAsset = (cite: CitationItem) => {
setIsMinimized(true);
const typeLower = (cite.type || '').toLowerCase();
const titleLower = (cite.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
navigate(isCurrentAdmin ? '/admin/showcase' : '/client/showcase', { state: { highlightAssetId: cite.assetId } });
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
navigate(isCurrentAdmin ? '/admin/legal' : '/client/agreements', { state: { highlightAssetId: cite.assetId } });
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
navigate(isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem', { state: { highlightAssetId: cite.assetId } });
} else {
navigate(isCurrentAdmin ? '/admin/assets' : '/client/assets', { state: { highlightAssetId: cite.assetId } });
}
};
const cycleSize = () => {
if (drawerSize === 'standard') setDrawerSize('wide');
else if (drawerSize === 'wide') setDrawerSize('maximized');
else setDrawerSize('standard');
};
const getDimensions = () => {
if (isMinimized) return { width: 'min(380px, 94vw)', height: '56px' };
if (drawerSize === 'maximized') return { width: 'min(880px, 96vw)', height: 'min(84vh, 900px)' };
if (drawerSize === 'wide') return { width: 'min(660px, 95vw)', height: 'min(700px, 82vh)' };
return { width: 'min(440px, 95vw)', height: 'min(600px, 80vh)' };
};
const dimensions = getDimensions();
return (
<>
{/* Draggable Floating Trigger Pill */}
{!isOpen && !hasActiveOverlay && (
<motion.button
drag
dragConstraints={{ left: -1200, right: 20, top: -800, bottom: 20 }}
dragElastic={0.1}
dragMomentum={false}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => { setIsOpen(true); setIsMinimized(false); }}
className="fixed bottom-14 right-6 sm:bottom-16 sm:right-8 z-30 flex items-center gap-2.5 px-4 py-3 bg-slate-900 text-white rounded-full shadow-2xl border border-slate-700/80 hover:border-emerald-500/80 transition-all cursor-grab active:cursor-grabbing group select-none"
>
<div className="p-1.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 group-hover:scale-110 transition-transform">
<Bot className="w-4 h-4" />
</div>
<span className="text-xs font-extrabold tracking-wide font-sans">
AI Advisor
</span>
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</motion.button>
)}
{/* Floating Glassmorphic Drawer Window */}
<AnimatePresence>
{isOpen && (
<motion.div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
height: dimensions.height,
width: dimensions.width
}}
exit={{ opacity: 0, y: 40, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
className="fixed sm:bottom-6 sm:right-6 bottom-0 right-0 left-0 sm:left-auto z-50 bg-slate-950/95 backdrop-blur-2xl border border-slate-800 sm:rounded-3xl rounded-t-2xl shadow-2xl flex flex-col overflow-hidden text-slate-100 font-sans max-w-full"
>
{/* Window Header (Clean Layout) */}
<div className="px-4 py-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-xs text-white">Tech4Biz AI Advisor</h3>
<p className="text-[10px] text-slate-400">Enterprise Intelligent Assistant</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* + New Chat Button */}
{!isMinimized && (
<button
type="button"
onClick={handleStartNewChat}
title="Start New Chat Session"
className="p-1.5 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 border border-emerald-500/40 text-[10px] font-bold flex items-center gap-1 transition-all cursor-pointer mr-1"
>
<Plus className="w-3.5 h-3.5" />
<span className="hidden sm:inline">New Chat</span>
</button>
)}
{/* Session History Toggle Button */}
{!isMinimized && (
<button
type="button"
onClick={handleToggleHistory}
title="Past Chat Sessions"
className={`p-1.5 rounded-lg border transition-colors cursor-pointer ${showHistory
? 'bg-emerald-500/30 text-emerald-300 border-emerald-500/50'
: 'text-slate-400 hover:text-white hover:bg-slate-800 border-slate-800'
}`}
>
<History className="w-3.5 h-3.5" />
</button>
)}
{/* Resize Drawer Cycle Button */}
{!isMinimized && (
<button
onClick={cycleSize}
title={`Current size: ${drawerSize.toUpperCase()}. Click to toggle window width.`}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{drawerSize === 'maximized' ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
)}
{/* Minimize Button */}
<button
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{isMinimized ? <Maximize2 className="w-3.5 h-3.5" /> : <Minimize2 className="w-3.5 h-3.5" />}
</button>
{/* Close Button */}
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Past Session History Drawer Side Panel */}
{showHistory && !isMinimized && (
<div className="bg-slate-900 border-b border-slate-800 p-3 max-h-48 overflow-y-auto space-y-2 shrink-0">
<div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
<span>Saved Past Sessions</span>
<span>{pastSessions.length} sessions</span>
</div>
{loadingHistory ? (
<div className="text-center p-3 text-xs text-slate-400 italic">
Loading chat history...
</div>
) : pastSessions.length === 0 ? (
<div className="text-center p-3 text-xs text-slate-500 italic">
No past sessions found. Start a conversation!
</div>
) : (
pastSessions.map((sess) => {
const firstUserMsg = sess.messages?.find(m => m.sender === 'USER')?.content || 'Chat Session';
const isActive = sess.id === sessionId;
return (
<div
key={sess.id}
onClick={() => handleLoadSession(sess.id)}
className={`p-2 rounded-xl border text-xs cursor-pointer flex items-center justify-between transition-all ${isActive
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
: 'bg-slate-950/70 hover:bg-slate-800 text-slate-300 border-slate-800'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span className="truncate text-[11px] font-medium">{firstUserMsg}</span>
</div>
<span className="text-[9px] font-mono text-slate-500 shrink-0 ml-2">
{new Date(sess.updatedAt || sess.createdAt).toLocaleDateString()}
</span>
</div>
);
})
)}
</div>
)}
{/* Content Body (Visible when not minimized) */}
{!isMinimized && (
<>
{/* Messages Stream */}
<div className="flex-1 p-4 overflow-y-auto space-y-4 text-xs">
{messages.map((msg, i) => (
<div
key={i}
className={`flex gap-2.5 ${msg.sender === 'USER' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`p-1.5 rounded-xl shrink-0 h-fit ${msg.sender === 'USER'
? 'bg-emerald-600 text-white'
: 'bg-slate-900 text-emerald-400 border border-slate-700'
}`}>
{msg.sender === 'USER' ? <UserIcon className="w-3.5 h-3.5" /> : <Bot className="w-3.5 h-3.5" />}
</div>
<div className={`space-y-2 max-w-[85%] ${msg.sender === 'USER' ? 'text-right' : 'text-left'}`}>
<div className={`p-3.5 rounded-2xl leading-relaxed ${msg.sender === 'USER'
? 'bg-emerald-600 text-white font-medium rounded-tr-none whitespace-pre-wrap'
: 'bg-slate-900 text-slate-100 border border-slate-800 rounded-tl-none shadow-md'
}`}>
{msg.sender === 'USER' ? (
msg.content
) : (
<MarkdownViewer markdown={msg.content} variant="dark" />
)}
</div>
{/* Citation Cards with Direct Preview & Location Redirection */}
{msg.citations && msg.citations.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-left">
Verified Knowledge Sources ({msg.citations.length}):
</p>
<div className="space-y-2">
{msg.citations.map((cite) => (
<div
key={cite.assetId}
className="group p-2.5 rounded-xl bg-slate-900/90 border border-slate-800 hover:border-emerald-500/50 transition-all text-left flex flex-col gap-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-[11px] text-white group-hover:text-emerald-300 truncate">
{cite.title}
</span>
{cite.isRecommended && (
<span className="px-1.5 py-0.2 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[8px] font-extrabold uppercase shrink-0">
Recommended
</span>
)}
</div>
<span className="text-[9px] text-slate-400 block font-mono">
{cite.location}
</span>
</div>
</div>
</div>
{/* Action Buttons: Preview & Go to Location */}
<div className="flex items-center gap-2 pt-1 border-t border-slate-800">
<button
type="button"
onClick={() => openAssetPreview(cite)}
className="flex-1 py-1 px-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Eye className="w-3 h-3 text-slate-400" />
<span>Quick Preview</span>
</button>
<button
type="button"
onClick={() => handleGoToAsset(cite)}
className="flex-1 py-1 px-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Compass className="w-3 h-3 text-emerald-400" />
<span>Go to Location</span>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex items-center gap-2.5 text-slate-400 text-xs italic p-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin text-emerald-400" />
Analyzing catalog knowledge...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Attached Entities Chip Bar */}
{attachedEntities.length > 0 && (
<div className="px-3 py-2 bg-slate-950 border-t border-slate-800 flex flex-wrap gap-1.5 shrink-0 max-h-28 overflow-y-auto">
<div className="w-full flex items-center justify-between text-[10px] font-extrabold uppercase tracking-wider text-amber-400">
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
Attached Workbench Entities ({attachedEntities.length}):
</span>
<button
onClick={() => setAttachedEntities([])}
className="text-slate-400 hover:text-slate-200 transition-colors text-[9px] cursor-pointer"
>
Clear All
</button>
</div>
{attachedEntities.map(ent => (
<span
key={ent.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-800 text-slate-100 border border-slate-700 shadow-sm"
>
<span className="text-[9px] font-black uppercase px-1.5 py-0.5 rounded bg-amber-500 text-slate-950">
{ent.entityKind}
</span>
<span className="truncate max-w-[160px] text-white">{ent.title}</span>
<button
onClick={() => setAttachedEntities(prev => prev.filter(x => x.id !== ent.id))}
className="hover:text-red-400 transition-colors text-slate-400 cursor-pointer ml-1"
title="Remove attached item"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
{/* Input Controls */}
<div className="p-3 bg-slate-900 border-t border-slate-800 flex gap-2 items-center shrink-0">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={attachedEntities.length > 0 ? "Ask AI to analyze or summarize attached items..." : "Ask AI or drag and drop items here..."}
disabled={loading}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/60 transition-all font-sans"
/>
<button
onClick={() => handleSend()}
disabled={loading || (!prompt.trim() && attachedEntities.length === 0)}
className="p-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 text-slate-950 rounded-xl transition-all font-bold cursor-pointer disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</button>
</div>
{/* Drag and Drop Target Zone Overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 bg-slate-950/90 backdrop-blur-md border-4 border-dashed border-amber-500 rounded-3xl flex flex-col items-center justify-center p-6 text-center animate-pulse">
<Sparkles className="w-12 h-12 text-amber-400 fill-amber-400 mb-3 animate-bounce" />
<h3 className="text-lg font-black text-slate-950 bg-amber-400 px-4 py-1 rounded-full shadow-lg">
Drop Entity Here for Instant AI Workbench Inspection
</h3>
<p className="text-xs font-bold text-slate-300 mt-2 max-w-xs leading-relaxed">
Attach catalog assets, case studies, partner offerings, or legal agreements to analyze and summarize.
</p>
</div>
)}
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* Asset Preview Modal Triggered from Citation Click */}
{activePreviewAsset && (
<AssetViewerModal
asset={activePreviewAsset}
isOpen={!!activePreviewAsset}
user={user}
onDownload={() => { }}
onClose={() => setActivePreviewAsset(null)}
/>
)}
</>
);
};

View File

@ -1,19 +1,16 @@
import React from "react"; import React from "react";
interface MarkdownBlock { interface MarkdownBlock {
type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p" | "table"; type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p";
content: string; content: string;
language?: string; language?: string;
items?: string[]; items?: string[];
headers?: string[];
rows?: string[][];
} }
interface ParseState { interface ParseState {
blocks: MarkdownBlock[]; blocks: MarkdownBlock[];
currentCodeBlock: { language: string; lines: string[] } | null; currentCodeBlock: { language: string; lines: string[] } | null;
currentList: { type: "ul" | "ol"; items: string[] } | null; currentList: { type: "ul" | "ol"; items: string[] } | null;
currentTableLines: string[];
currentParagraphLines: string[]; currentParagraphLines: string[];
} }
@ -44,36 +41,6 @@ const flushList = (state: ParseState): void => {
} }
}; };
const flushTable = (state: ParseState): void => {
if (state.currentTableLines.length > 0) {
const rawLines = state.currentTableLines;
state.currentTableLines = [];
// Filter out separator lines like |---|---|
const parsedRows = rawLines
.filter(line => !/^[|\s-:]+$/.test(line.trim()))
.map(line => {
const cells = line.split('|').map(c => c.trim());
// Remove empty lead/trail cells from leading/trailing pipes
if (cells.length > 0 && cells[0] === '') cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();
return cells;
})
.filter(row => row.length > 0);
if (parsedRows.length > 0) {
const headers = parsedRows[0];
const rows = parsedRows.slice(1);
state.blocks.push({
type: "table",
content: "",
headers,
rows,
});
}
}
};
const handleCodeBlock = (trimmed: string, state: ParseState): boolean => { const handleCodeBlock = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith("```")) { if (trimmed.startsWith("```")) {
if (state.currentCodeBlock) { if (state.currentCodeBlock) {
@ -86,7 +53,6 @@ const handleCodeBlock = (trimmed: string, state: ParseState): boolean => {
} else { } else {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
const language = trimmed.slice(3).trim(); const language = trimmed.slice(3).trim();
state.currentCodeBlock = { language, lines: [] }; state.currentCodeBlock = { language, lines: [] };
} }
@ -100,7 +66,6 @@ const handleHeading = (line: string, state: ParseState): boolean => {
if (match) { if (match) {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
const level = match[1].length; const level = match[1].length;
state.blocks.push({ state.blocks.push({
type: `h${level}` as any, type: `h${level}` as any,
@ -115,7 +80,6 @@ const handleBlockquote = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith(">")) { if (trimmed.startsWith(">")) {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
state.blocks.push({ state.blocks.push({
type: "blockquote", type: "blockquote",
content: trimmed.replace(/^>\s*/, ""), content: trimmed.replace(/^>\s*/, ""),
@ -129,7 +93,6 @@ const handleLists = (line: string, state: ParseState): boolean => {
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/); const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
if (ulMatch) { if (ulMatch) {
flushParagraph(state); flushParagraph(state);
flushTable(state);
const content = ulMatch[3].trim(); const content = ulMatch[3].trim();
if (state.currentList && state.currentList.type === "ul") { if (state.currentList && state.currentList.type === "ul") {
state.currentList.items.push(content); state.currentList.items.push(content);
@ -143,7 +106,6 @@ const handleLists = (line: string, state: ParseState): boolean => {
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) { if (olMatch) {
flushParagraph(state); flushParagraph(state);
flushTable(state);
const content = olMatch[3].trim(); const content = olMatch[3].trim();
if (state.currentList && state.currentList.type === "ol") { if (state.currentList && state.currentList.type === "ol") {
state.currentList.items.push(content); state.currentList.items.push(content);
@ -157,17 +119,6 @@ const handleLists = (line: string, state: ParseState): boolean => {
return false; return false;
}; };
const handleTableLine = (trimmed: string, state: ParseState): boolean => {
// Check if line contains markdown table pipes
if (trimmed.includes("|") && (trimmed.startsWith("|") || trimmed.includes(" | ") || /^[-|\s:]+$/.test(trimmed))) {
flushParagraph(state);
flushList(state);
state.currentTableLines.push(trimmed);
return true;
}
return false;
};
const handleLine = (line: string, state: ParseState): void => { const handleLine = (line: string, state: ParseState): void => {
const trimmed = line.trim(); const trimmed = line.trim();
@ -183,7 +134,6 @@ const handleLine = (line: string, state: ParseState): void => {
if (trimmed === "---" || trimmed === "***" || trimmed === "___") { if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
state.blocks.push({ type: "hr", content: "" }); state.blocks.push({ type: "hr", content: "" });
return; return;
} }
@ -196,73 +146,22 @@ const handleLine = (line: string, state: ParseState): void => {
return; return;
} }
if (handleTableLine(trimmed, state)) {
return;
}
if (trimmed === "") { if (trimmed === "") {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
return; return;
} }
flushList(state); flushList(state);
flushTable(state);
state.currentParagraphLines.push(line); state.currentParagraphLines.push(line);
}; };
/**
* Preprocesses raw markdown text to split single-line concatenated markdown table rows and merge split table rows across lines.
*/
const sanitizeMarkdownText = (rawText: string): string => {
if (!rawText) return "";
const lines = rawText.split("\n");
const processedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// If the line starts with a pipe but doesn't end with one, it is likely split across linebreaks
if (trimmed.startsWith("|") && !trimmed.endsWith("|")) {
let merged = line;
while (i + 1 < lines.length) {
const nextLine = lines[i + 1];
const nextTrimmed = nextLine.trim();
merged += " " + nextTrimmed;
i++;
if (nextTrimmed.endsWith("|")) {
break;
}
}
processedLines.push(merged);
} else {
processedLines.push(line);
}
}
let formatted = processedLines.join("\n");
// Split inline concatenated table rows like "| Col 1 | Col 2 | | :--- | :--- | | Val 1 | Val 2 |"
formatted = formatted.replace(/\|\s*\|\s*:-/g, "|\n| :-");
formatted = formatted.replace(/\|\s*\|\s*([A-Za-z0-9_*`])/g, "|\n| $1");
formatted = formatted.replace(/([^\n|])\s*(\|[\s\S]+?\|)\s*([^\n|])/g, "$1\n\n$2\n\n$3");
// Clean up repeated linebreaks
formatted = formatted.replace(/\n{3,}/g, "\n\n");
return formatted;
};
export const parseMarkdown = (text: string): MarkdownBlock[] => { export const parseMarkdown = (text: string): MarkdownBlock[] => {
const sanitized = sanitizeMarkdownText(text); const lines = text.split("\n");
const lines = sanitized.split("\n");
const state: ParseState = { const state: ParseState = {
blocks: [], blocks: [],
currentCodeBlock: null, currentCodeBlock: null,
currentList: null, currentList: null,
currentTableLines: [],
currentParagraphLines: [], currentParagraphLines: [],
}; };
@ -272,7 +171,6 @@ export const parseMarkdown = (text: string): MarkdownBlock[] => {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
return state.blocks; return state.blocks;
}; };
@ -387,7 +285,7 @@ const parseInlineItalic = (tokens: InlineToken[]): InlineToken[] => {
return updated; return updated;
}; };
export const renderInlineText = (text: string, isDark: boolean = false): React.ReactNode[] => { export const renderInlineText = (text: string): React.ReactNode[] => {
if (!text) return []; if (!text) return [];
let tokens: InlineToken[] = [{ type: "text", text }]; let tokens: InlineToken[] = [{ type: "text", text }];
@ -400,15 +298,11 @@ export const renderInlineText = (text: string, isDark: boolean = false): React.R
return tokens.map((part, idx) => { return tokens.map((part, idx) => {
switch (part.type) { switch (part.type) {
case "bold": case "bold":
return <strong key={idx} className={isDark ? "font-extrabold text-white" : "font-extrabold text-ink-900"}>{part.text}</strong>; return <strong key={idx} className="font-extrabold text-ink-900">{part.text}</strong>;
case "italic": case "italic":
return <em key={idx} className={isDark ? "italic text-slate-200" : "italic text-ink-800"}>{part.text}</em>; return <em key={idx} className="italic text-ink-800">{part.text}</em>;
case "code": case "code":
return ( return <code key={idx} className="bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700">{part.text}</code>;
<code key={idx} className={isDark ? "bg-slate-900 border border-slate-700 text-emerald-300 rounded px-1.5 py-0.5 text-xs font-mono" : "bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700"}>
{part.text}
</code>
);
case "link": case "link":
return ( return (
<a <a
@ -416,7 +310,7 @@ export const renderInlineText = (text: string, isDark: boolean = false): React.R
href={part.url} href={part.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={isDark ? "text-emerald-400 hover:text-emerald-300 font-semibold underline break-all inline-flex items-center gap-0.5" : "text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"} className="text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"
> >
{part.text} {part.text}
</a> </a>
@ -427,121 +321,82 @@ export const renderInlineText = (text: string, isDark: boolean = false): React.R
}); });
}; };
const renderListBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => { const renderListBlock = (block: MarkdownBlock, key: string): React.ReactNode => {
const Component = block.type === "ul" ? "ul" : "ol"; const Component = block.type === "ul" ? "ul" : "ol";
const listClass = block.type === "ul" const listClass = block.type === "ul"
? `list-disc pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}` ? "list-disc pl-6 space-y-1.5 my-2.5 text-sm text-ink-800"
: `list-decimal pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`; : "list-decimal pl-6 space-y-1.5 my-2.5 text-sm text-ink-800";
return ( return (
<Component key={key} className={listClass}> <Component key={key} className={listClass}>
{block.items?.map((item, idx) => ( {block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item, isDark)}</li> <li key={idx}>{renderInlineText(item)}</li>
))} ))}
</Component> </Component>
); );
}; };
const renderHeadingBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => { const renderHeadingBlock = (block: MarkdownBlock, key: string): React.ReactNode => {
const level = block.type.slice(1); const level = block.type.slice(1);
const classes: Record<string, string> = { const classes: Record<string, string> = {
"1": `text-lg sm:text-xl font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-4 mb-2 border-b border-slate-700/50 pb-1`, "1": "text-2xl sm:text-3xl font-extrabold text-ink-900 mt-6 mb-3 border-b border-ink-150 pb-2",
"2": `text-base sm:text-lg font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-3 mb-1.5`, "2": "text-xl sm:text-2xl font-extrabold text-ink-900 mt-5 mb-2.5",
"3": `text-sm sm:text-base font-bold ${isDark ? 'text-emerald-400' : 'text-ink-900'} mt-2.5 mb-1`, "3": "text-lg sm:text-xl font-bold text-ink-900 mt-4 mb-2",
"4": `text-xs sm:text-sm font-bold ${isDark ? 'text-emerald-300' : 'text-ink-800'} mt-2 mb-1`, "4": "text-base sm:text-lg font-bold text-ink-800 mt-3 mb-1.5",
"5": `text-xs font-bold ${isDark ? 'text-slate-200' : 'text-ink-800'} mt-1.5 mb-1`, "5": "text-sm sm:text-base font-bold text-ink-800 mt-2.5 mb-1.5",
"6": `text-xs font-bold ${isDark ? 'text-slate-300' : 'text-ink-700'} mt-1 mb-0.5`, "6": "text-xs sm:text-sm font-bold text-ink-700 mt-2 mb-1",
}; };
const Component = block.type as any; const Component = block.type as any;
return ( return (
<Component key={key} className={classes[level] || ""}> <Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content, isDark)} {renderInlineText(block.content)}
</Component> </Component>
); );
}; };
const renderTableBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => { const renderBlock = (block: MarkdownBlock, index: number): React.ReactNode => {
if (!block.headers || block.headers.length === 0) return null;
return (
<div key={key} className="my-3 w-full overflow-x-auto rounded-xl border border-slate-800 bg-slate-950/90 shadow-xl max-w-full">
<table className="w-full text-left border-collapse min-w-[320px]">
<thead>
<tr className="bg-slate-900/90 border-b border-slate-800 text-[11px] font-extrabold text-emerald-400 uppercase tracking-wider">
{block.headers.map((h, i) => (
<th key={i} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/80 font-bold whitespace-nowrap">
{renderInlineText(h, isDark)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{(block.rows || []).map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-slate-900/60 transition-colors odd:bg-slate-950/40 even:bg-slate-900/30">
{row.map((cell, cIdx) => (
<td key={cIdx} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/60 text-slate-200 leading-relaxed font-sans">
{renderInlineText(cell, isDark)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
const renderBlock = (block: MarkdownBlock, index: number, isDark: boolean): React.ReactNode => {
const key = `${block.type}-${index}`; const key = `${block.type}-${index}`;
if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") { if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") {
return renderHeadingBlock(block, key, isDark); return renderHeadingBlock(block, key);
} }
switch (block.type) { switch (block.type) {
case "table":
return renderTableBlock(block, key, isDark);
case "blockquote": case "blockquote":
return ( return (
<blockquote key={key} className={`border-l-4 border-emerald-500 pl-3 py-1 italic ${isDark ? 'bg-slate-900/60 text-slate-200' : 'bg-primary-50/20 text-ink-700'} my-2 rounded-r-lg`}> <blockquote key={key} className="border-l-4 border-primary-500 pl-4 py-1 italic bg-primary-50/20 text-ink-700 my-3 rounded-r-lg">
{renderInlineText(block.content, isDark)} {renderInlineText(block.content)}
</blockquote> </blockquote>
); );
case "ul": case "ul":
case "ol": case "ol":
return renderListBlock(block, key, isDark); return renderListBlock(block, key);
case "code": case "code":
return ( return (
<div key={key} className="my-3 rounded-xl border border-slate-800 bg-slate-950 text-slate-100 p-3 overflow-x-auto shadow-inner relative group select-text max-w-full"> <div key={key} className="my-4 rounded-xl border border-ink-200 bg-ink-900 text-ink-100 p-4 overflow-x-auto shadow-inner relative group select-text">
{block.language && ( {block.language && (
<div className="absolute right-3 top-2 text-[9px] uppercase font-bold text-slate-400 select-none"> <div className="absolute right-3 top-3 text-[10px] uppercase font-bold text-ink-400 select-none">
{block.language} {block.language}
</div> </div>
)} )}
<pre className="font-mono text-xs leading-relaxed overflow-x-auto"> <pre className="font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
{block.content} {block.content}
</pre> </pre>
</div> </div>
); );
case "hr": case "hr":
return <hr key={key} className={`my-4 ${isDark ? 'border-slate-800' : 'border-ink-200'}`} />; return <hr key={key} className="border-t border-ink-200 my-6" />;
default: default:
return ( return <p key={key} className="text-sm text-ink-800 leading-relaxed my-2">{renderInlineText(block.content)}</p>;
<p key={key} className={`text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'} leading-relaxed my-1.5`}>
{renderInlineText(block.content, isDark)}
</p>
);
} }
}; };
export interface MarkdownViewerProps { export interface MarkdownViewerProps {
markdown: string; markdown: string;
variant?: 'light' | 'dark' | 'auto';
} }
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown, variant = 'auto' }) => { export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown }) => {
const blocks = parseMarkdown(markdown); const blocks = parseMarkdown(markdown);
const isDark = variant === 'dark';
return ( return (
<div className={`w-full text-left select-text font-sans leading-relaxed break-words overflow-hidden ${isDark ? 'text-slate-100' : 'text-ink-800'}`}> <div className="w-full text-left select-text font-sans leading-relaxed text-ink-800">
{blocks.map((block, idx) => renderBlock(block, idx, isDark))} {blocks.map((block, idx) => renderBlock(block, idx))}
</div> </div>
); );
}; };

View File

@ -1,909 +0,0 @@
import React, { useState } from 'react';
import { X, Shield, Plus, Trash2, Tag, CheckCircle2, AlertCircle, Bell, Layers, Cpu, ShieldCheck, Send } from 'lucide-react';
import type { TaxonomyMeta, Organization, Asset } from '../../../types/assets';
import {
createVertical, deleteVertical,
createTechStack, deleteTechStack,
createEngagementType, deleteEngagementType,
createComplianceStandard, deleteComplianceStandard,
sendAssetAnnouncement
} from '../../../services/assets-api';
interface AssetAdminManagerModalProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
organizations: Organization[];
allAssets?: Asset[];
onRefreshMeta: () => void;
}
export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
isOpen,
onClose,
meta,
organizations,
allAssets = [],
onRefreshMeta,
}) => {
const [activeTab, setActiveTab] = useState<'verticals' | 'techStacks' | 'engagements' | 'compliance' | 'taxonomy' | 'announcements'>('verticals');
const [loading, setLoading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Inspect filter state for Taxonomy Stats interactive list
const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null);
// Form State: Verticals
const [newVerticalName, setNewVerticalName] = useState('');
const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6');
const [newVerticalDesc, setNewVerticalDesc] = useState('');
// Form State: Tech Stacks
const [newTechName, setNewTechName] = useState('');
const [newTechCategory, setNewTechCategory] = useState('Languages & Frameworks');
const [newTechColor, setNewTechColor] = useState('#64748b');
// Form State: Engagements
const [newEngagementName, setNewEngagementName] = useState('');
const [newEngagementColor, setNewEngagementColor] = useState('#0284c7');
// Form State: Compliance
const [newComplianceName, setNewComplianceName] = useState('');
const [newComplianceColor, setNewComplianceColor] = useState('#10b981');
// Announcement Form State
const [announcementTitle, setAnnouncementTitle] = useState('');
const [announcementMsg, setAnnouncementMsg] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState<string>('ALL');
if (!isOpen) return null;
const matchingAssets = (allAssets || []).filter(asset => {
if (!inspectFilter) return false;
if (inspectFilter.type === 'Subcategory') {
return (asset.subcategory || asset.categoryId || '').toLowerCase() === inspectFilter.name.toLowerCase();
}
if (inspectFilter.type === 'Vertical') {
return asset.verticals?.some(v => v.id === inspectFilter.id || v.name.toLowerCase() === inspectFilter.name.toLowerCase());
}
return false;
});
// Vertical CRUD
const handleCreateVertical = async (e: React.FormEvent) => {
e.preventDefault();
if (!newVerticalName.trim()) return;
setLoading(true);
try {
await createVertical({
name: newVerticalName.trim(),
color: newVerticalColor,
description: newVerticalDesc.trim() || undefined,
icon: 'Shield',
});
setStatusMsg({ type: 'success', text: `Vertical "${newVerticalName}" created successfully!` });
setNewVerticalName('');
setNewVerticalDesc('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create vertical' });
} finally {
setLoading(false);
}
};
const handleDeleteVertical = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete vertical "${name}"?`)) return;
setLoading(true);
try {
await deleteVertical(id);
setStatusMsg({ type: 'success', text: `Vertical "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete vertical' });
} finally {
setLoading(false);
}
};
// Tech Stack CRUD
const handleCreateTechStack = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTechName.trim()) return;
setLoading(true);
try {
await createTechStack({
name: newTechName.trim(),
category: newTechCategory,
color: newTechColor,
});
setStatusMsg({ type: 'success', text: `Tech Stack item "${newTechName}" created successfully!` });
setNewTechName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create tech stack' });
} finally {
setLoading(false);
}
};
const handleDeleteTechStack = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete tech stack item "${name}"?`)) return;
setLoading(true);
try {
await deleteTechStack(id);
setStatusMsg({ type: 'success', text: `Tech Stack item "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete tech stack item' });
} finally {
setLoading(false);
}
};
// Engagement Type CRUD
const handleCreateEngagement = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEngagementName.trim()) return;
setLoading(true);
try {
await createEngagementType({
name: newEngagementName.trim(),
color: newEngagementColor,
});
setStatusMsg({ type: 'success', text: `Engagement Type "${newEngagementName}" created!` });
setNewEngagementName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create engagement type' });
} finally {
setLoading(false);
}
};
const handleDeleteEngagement = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete engagement type "${name}"?`)) return;
setLoading(true);
try {
await deleteEngagementType(id);
setStatusMsg({ type: 'success', text: `Engagement Type "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete engagement type' });
} finally {
setLoading(false);
}
};
// Compliance Standard CRUD
const handleCreateCompliance = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComplianceName.trim()) return;
setLoading(true);
try {
await createComplianceStandard({
name: newComplianceName.trim(),
color: newComplianceColor,
});
setStatusMsg({ type: 'success', text: `Compliance Standard "${newComplianceName}" created!` });
setNewComplianceName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create compliance standard' });
} finally {
setLoading(false);
}
};
const handleDeleteCompliance = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete compliance standard "${name}"?`)) return;
setLoading(true);
try {
await deleteComplianceStandard(id);
setStatusMsg({ type: 'success', text: `Compliance Standard "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete compliance standard' });
} finally {
setLoading(false);
}
};
const handleSendAnnouncement = async (e: React.FormEvent) => {
e.preventDefault();
if (!announcementTitle.trim() || !announcementMsg.trim()) return;
setLoading(true);
try {
await sendAssetAnnouncement({
title: announcementTitle.trim(),
message: announcementMsg.trim(),
targetOrgIds: selectedOrgId === 'ALL' ? ['ALL'] : [selectedOrgId],
});
setStatusMsg({ type: 'success', text: 'Announcement dispatched to partner organizations!' });
setAnnouncementTitle('');
setAnnouncementMsg('');
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to send announcement' });
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[85vh]">
{/* Modal Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 shadow-md">
<Shield className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
Taxonomy & Announcements Control Panel
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Manage 4-Group Taxonomy items, metadata stats, and partner notifications
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center overflow-x-auto whitespace-nowrap custom-scrollbar border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-800/50 px-4 scroll-smooth shrink-0">
<button
onClick={() => { setActiveTab('verticals'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'verticals'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Shield className="w-3.5 h-3.5" />
1. Verticals ({meta?.verticals.length || 0})
</button>
<button
onClick={() => { setActiveTab('techStacks'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'techStacks'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Cpu className="w-3.5 h-3.5" />
2. Tech Stack ({meta?.techStacks?.length || 0})
</button>
<button
onClick={() => { setActiveTab('engagements'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'engagements'
? 'border-sky-600 text-sky-600 dark:border-sky-400 dark:text-sky-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Layers className="w-3.5 h-3.5" />
3. Engagements ({meta?.engagementTypes?.length || 0})
</button>
<button
onClick={() => { setActiveTab('compliance'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'compliance'
? 'border-emerald-600 text-emerald-600 dark:border-emerald-400 dark:text-emerald-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
4. Compliance ({meta?.complianceStandards?.length || 0})
</button>
<button
onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'taxonomy'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Tag className="w-3.5 h-3.5" />
Stats ({meta?.totalAssets || 0})
</button>
<button
onClick={() => { setActiveTab('announcements'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'announcements'
? 'border-amber-500 text-amber-500 dark:border-amber-400 dark:text-amber-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Bell className="w-3.5 h-3.5" />
Announcements
</button>
</div>
{/* Status Message */}
{statusMsg && (
<div
className={`px-6 py-3 text-xs font-medium flex items-center gap-2 ${
statusMsg.type === 'success'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
: 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
{statusMsg.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{statusMsg.text}
</div>
)}
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
{/* TAB 1: Verticals */}
{activeTab === 'verticals' && (
<div className="space-y-6">
{/* Create Vertical Form */}
<form onSubmit={handleCreateVertical} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Industry Vertical
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Vertical Name
</label>
<input
type="text"
placeholder="e.g. CleanTech & Renewables"
value={newVerticalName}
onChange={e => setNewVerticalName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-slate-400"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-9 h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
<input
type="text"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-full px-2 py-1.5 text-xs font-mono rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
/>
</div>
</div>
</div>
<div>
<input
type="text"
placeholder="Short description of this vertical domain..."
value={newVerticalDesc}
onChange={e => setNewVerticalDesc(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newVerticalName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Vertical
</button>
</div>
</form>
{/* Verticals Table */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Verticals ({meta?.verticals.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.verticals || []).map(v => (
<div key={v.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#6366f1' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{v.name}
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{v._count?.assets ?? 0} assets
</span>
</div>
{v.description && <div className="text-[11px] text-slate-400">{v.description}</div>}
</div>
</div>
<button
onClick={() => handleDeleteVertical(v.id, v.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Vertical"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Tech Stacks */}
{activeTab === 'techStacks' && (
<div className="space-y-6">
{/* Create Tech Stack Form */}
<form onSubmit={handleCreateTechStack} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Tech Stack / Capability Item
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Tech Item Name
</label>
<input
type="text"
placeholder="e.g. Rust, PyTorch, GraphQL"
value={newTechName}
onChange={e => setNewTechName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Tech Category / Group
</label>
<select
value={newTechCategory}
onChange={e => setNewTechCategory(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="Languages & Frameworks">Languages & Frameworks</option>
<option value="AI & ML">AI & ML</option>
<option value="Data & Backend">Data & Backend</option>
<option value="Cloud & Infra">Cloud & Infra</option>
</select>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Color
</label>
<input
type="color"
value={newTechColor}
onChange={e => setNewTechColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newTechName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Tech Item
</button>
</div>
</form>
{/* Tech Stack List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Tech Stack Items ({meta?.techStacks?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.techStacks || []).map(t => (
<div key={t.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: t.color || '#64748b' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{t.name}
<span className="text-[10px] font-extrabold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">
{t.category}
</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{t._count?.assets ?? 0} assets
</span>
</div>
</div>
</div>
<button
onClick={() => handleDeleteTechStack(t.id, t.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Tech Item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 3: Engagement Types */}
{activeTab === 'engagements' && (
<div className="space-y-6">
{/* Create Engagement Form */}
<form onSubmit={handleCreateEngagement} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Engagement Type
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Engagement Name
</label>
<input
type="text"
placeholder="e.g. Audit & Advisory, Advisory"
value={newEngagementName}
onChange={e => setNewEngagementName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<input
type="color"
value={newEngagementColor}
onChange={e => setNewEngagementColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newEngagementName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Engagement Type
</button>
</div>
</form>
{/* Engagements List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Engagement Types ({meta?.engagementTypes?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.engagementTypes || []).map(e => (
<div key={e.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: e.color || '#0284c7' }} />
<span className="font-semibold text-slate-900 dark:text-slate-100">{e.name}</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{e._count?.assets ?? 0} assets
</span>
</div>
<button
onClick={() => handleDeleteEngagement(e.id, e.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Engagement Type"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 4: Compliance Standards */}
{activeTab === 'compliance' && (
<div className="space-y-6">
{/* Create Compliance Form */}
<form onSubmit={handleCreateCompliance} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Compliance Standard / Regulatory Certification
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Compliance Name
</label>
<input
type="text"
placeholder="e.g. ISO 27001, PCI-DSS, FedRAMP"
value={newComplianceName}
onChange={e => setNewComplianceName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<input
type="color"
value={newComplianceColor}
onChange={e => setNewComplianceColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newComplianceName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Compliance Standard
</button>
</div>
</form>
{/* Compliance List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Compliance Standards ({meta?.complianceStandards?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.complianceStandards || []).map(c => (
<div key={c.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: c.color || '#10b981' }} />
<span className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{c.name}
</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{c._count?.assets ?? 0} assets
</span>
</div>
<button
onClick={() => handleDeleteCompliance(c.id, c.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Compliance Standard"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Taxonomy Stats */}
{activeTab === 'taxonomy' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2.5">
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">Total Assets</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-blue-600">{meta?.verticals.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">1. Verticals</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-purple-600">{meta?.techStacks?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">2. Tech Stacks</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-sky-600">{meta?.engagementTypes?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">3. Engagements</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-emerald-600">{meta?.complianceStandards?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">4. Compliance</div>
</div>
</div>
{/* 1. Industry Verticals Breakdown */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
1. Industry Verticals Domain Distribution
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{(meta?.verticals || []).map(v => (
<button
key={v.id}
onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })}
className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer flex items-center justify-between ${
inspectFilter?.name === v.name
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} />
<span className="font-bold text-xs">{v.name}</span>
</div>
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-700">
{v._count?.assets ?? 0} assets
</span>
</button>
))}
</div>
</div>
{/* 2. Tech Stack Breakdown */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
2. Technology Stack & Capabilities
</h4>
<div className="flex flex-wrap gap-1.5">
{(meta?.techStacks || []).map(t => (
<div
key={t.id}
className="px-2.5 py-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-xs font-medium flex items-center gap-1.5"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
<span className="font-semibold">{t.name}</span>
<span className="text-[10px] font-mono opacity-70">({t._count?.assets ?? 0})</span>
</div>
))}
</div>
</div>
{/* 3 & 4. Engagement & Compliance Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
3. Engagement Types
</h4>
<div className="space-y-1.5">
{(meta?.engagementTypes || []).map(e => (
<div key={e.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
<span>{e.name}</span>
<span className="font-mono text-[10px] opacity-70">({e._count?.assets ?? 0} assets)</span>
</div>
))}
</div>
</div>
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
4. Compliance & Regulatory
</h4>
<div className="space-y-1.5">
{(meta?.complianceStandards || []).map(c => (
<div key={c.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
<span className="flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{c.name}
</span>
<span className="font-mono text-[10px] opacity-70">({c._count?.assets ?? 0} assets)</span>
</div>
))}
</div>
</div>
</div>
{/* Inspect Filter Asset List Details */}
{inspectFilter && (
<div className="p-4 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-700 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-xs font-extrabold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Tag className="w-4 h-4 text-amber-500" />
Inspecting {inspectFilter.type}: <span className="underline">{inspectFilter.name}</span>
</h5>
<button
onClick={() => setInspectFilter(null)}
className="text-[11px] font-bold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Clear Selection
</button>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-700/60 max-h-48 overflow-y-auto custom-scrollbar bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700">
{matchingAssets.length === 0 ? (
<div className="p-4 text-xs text-center text-slate-500">No assets tagged with this {inspectFilter.type.toLowerCase()} yet.</div>
) : (
matchingAssets.map(asset => (
<div key={asset.id} className="p-2.5 flex items-center justify-between text-xs hover:bg-slate-50 dark:hover:bg-slate-800/50">
<div className="min-w-0 flex-1 pr-3">
<div className="font-bold text-slate-900 dark:text-slate-100 truncate">{asset.title}</div>
<div className="text-[10px] text-slate-500 font-mono truncate">{asset.subcategory || asset.type} {asset.url}</div>
</div>
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="px-2.5 py-1 text-[10px] font-bold bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 hover:bg-slate-200"
>
View Asset
</a>
</div>
))
)}
</div>
</div>
)}
</div>
)}
{/* TAB 3: Announcements */}
{activeTab === 'announcements' && (
<form onSubmit={handleSendAnnouncement} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Target Partner Organization
</label>
<select
value={selectedOrgId}
onChange={e => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="ALL">All Partner Organizations (Broadcast)</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Announcement Title
</label>
<input
type="text"
placeholder="e.g. New Cybersecurity Case Studies & MVPs Released"
value={announcementTitle}
onChange={e => setAnnouncementTitle(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Message Body
</label>
<textarea
rows={5}
placeholder="Write announcement details for partners..."
value={announcementMsg}
onChange={e => setAnnouncementMsg(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 custom-scrollbar"
/>
</div>
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={loading || !announcementTitle.trim() || !announcementMsg.trim()}
className="px-5 py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md disabled:opacity-50 transition-all flex items-center gap-2"
>
<Send className="w-4 h-4" />
Dispatch Announcement
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};

View File

@ -45,7 +45,7 @@ interface AssetCardProps {
onDownload: (asset: Asset) => void; onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void; onRequestDownload: (asset: Asset) => void;
isSelected?: boolean; isSelected?: boolean;
onToggleSelect?: (assetId: string, event?: React.MouseEvent) => void; onToggleSelect?: (assetId: string) => void;
isExpanded?: boolean; isExpanded?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
isRecommended?: boolean; isRecommended?: boolean;
@ -142,44 +142,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
const hasBanner = isImage || !!asset.thumbnailUrl; const hasBanner = isImage || !!asset.thumbnailUrl;
const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url; const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
const handleInspectWithAI = (e: React.MouseEvent) => {
e.stopPropagation();
const payload = {
id: asset.id,
title: asset.title,
type: asset.type,
entityKind: 'ASSET',
url: asset.url,
description: asset.description,
problemStatement: asset.problemStatement,
solution: asset.solution,
thumbnailUrl: asset.thumbnailUrl,
tags: asset.tags,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
};
return ( return (
<motion.div <motion.div
id={`asset-card-${asset.id}`}
layout layout
draggable={true}
onDragStart={(e: any) => {
const payload = {
id: asset.id,
title: asset.title,
type: asset.type,
entityKind: 'ASSET',
url: asset.url,
description: asset.description,
problemStatement: asset.problemStatement,
solution: asset.solution,
thumbnailUrl: asset.thumbnailUrl,
tags: asset.tags,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', asset.title);
}}
transition={{ type: "spring", stiffness: 320, damping: 28 }} transition={{ type: "spring", stiffness: 320, damping: 28 }}
onClick={(e) => { onClick={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@ -188,7 +153,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
} }
onViewDetails(asset); onViewDetails(asset);
}} }}
className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${isExpanded className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0' ? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
: isRecommended : isRecommended
? 'relative w-full h-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5' ? 'relative w-full h-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5'
@ -204,10 +170,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onClick={(e) => e.stopPropagation()} onChange={() => onToggleSelect(asset.id)}
onChange={(e) => onToggleSelect(asset.id, e as unknown as React.MouseEvent)} className="w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm"
className={`w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
/> />
)} )}
{isRecommended && ( {isRecommended && (
@ -219,19 +183,10 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
<div className="absolute top-2 right-2 z-10 flex items-center gap-1"> <div className="absolute top-2 right-2 z-10 flex items-center gap-1">
<button
onClick={handleInspectWithAI}
className="px-2 py-1 rounded-md bg-ink-900 text-ink-0 hover:bg-ink-800 text-[10px] font-bold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
{isRenderable(asset.type, asset.url) && ( {isRenderable(asset.type, asset.url) && (
<button <button
onClick={() => onOpenViewer(asset)} onClick={() => onOpenViewer(asset)}
className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105 cursor-pointer" className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105"
title="Preview Online" title="Preview Online"
> >
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3.5 h-3.5" />
@ -241,7 +196,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<div className="relative"> <div className="relative">
<button <button
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)} onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105 cursor-pointer" className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105"
> >
<MoreVertical className="w-3.5 h-3.5" /> <MoreVertical className="w-3.5 h-3.5" />
</button> </button>
@ -249,17 +204,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
{isMenuOpen && ( {isMenuOpen && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} /> <div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<div className="absolute right-0 mt-1.5 w-48 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1"> <div className="absolute right-0 mt-1.5 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
<button
onClick={(e) => {
handleInspectWithAI(e);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-bold text-amber-650 hover:bg-amber-50 flex items-center gap-2"
>
<Sparkles className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
Inspect with AI Advisor
</button>
<button <button
onClick={() => { onClick={() => {
onViewDetails(asset); onViewDetails(asset);
@ -352,10 +297,10 @@ export const AssetCard: React.FC<AssetCardProps> = ({
url={getFullAssetUrl(asset.url)} url={getFullAssetUrl(asset.url)}
fallback={ fallback={
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between"> <div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
<div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" /> <div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" />
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5"> <div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span> <span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-slate-800">DOCX</span> <span className="font-extrabold text-indigo-650">DOCX</span>
</div> </div>
<div className="space-y-2 flex-grow mt-3"> <div className="space-y-2 flex-grow mt-3">
<h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2"> <h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2">
@ -435,7 +380,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
{/* Browser Body Web Mockup */} {/* Browser Body Web Mockup */}
<div className="flex-1 bg-slate-950 p-3 flex flex-col justify-between relative overflow-hidden"> <div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-3 flex flex-col justify-between relative overflow-hidden">
{/* Decorative Grid Pattern */} {/* Decorative Grid Pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" /> <div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" />
@ -443,7 +388,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<div className="space-y-2 mt-1.5 relative z-10"> <div className="space-y-2 mt-1.5 relative z-10">
{/* Mock Navbar */} {/* Mock Navbar */}
<div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5"> <div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5">
<div className="w-8 h-1.5 bg-slate-700 rounded" /> <div className="w-8 h-1.5 bg-indigo-400/30 rounded" />
<div className="flex gap-1"> <div className="flex gap-1">
<div className="w-4 h-1 bg-white/20 rounded" /> <div className="w-4 h-1 bg-white/20 rounded" />
<div className="w-4 h-1 bg-white/20 rounded" /> <div className="w-4 h-1 bg-white/20 rounded" />
@ -451,7 +396,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
{/* Mock Page Content */} {/* Mock Page Content */}
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">
<div className="h-2.5 bg-slate-700 rounded w-3/4" /> <div className="h-2.5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded w-3/4" />
<div className="h-1.5 bg-white/20 rounded w-11/12" /> <div className="h-1.5 bg-white/20 rounded w-11/12" />
<div className="h-1.5 bg-white/10 rounded w-5/6" /> <div className="h-1.5 bg-white/10 rounded w-5/6" />
</div> </div>
@ -459,7 +404,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
{/* Mock Card Domain Display */} {/* Mock Card Domain Display */}
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none"> <div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none">
<div className="w-6 h-6 rounded bg-slate-800 flex items-center justify-center text-[10px] font-black text-white shrink-0"> <div className="w-6 h-6 rounded bg-indigo-650 flex items-center justify-center text-[10px] font-black text-white shrink-0">
{getFriendlyHostname(asset.url).charAt(0).toUpperCase()} {getFriendlyHostname(asset.url).charAt(0).toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0 text-left"> <div className="flex-1 min-w-0 text-left">
@ -520,12 +465,12 @@ export const AssetCard: React.FC<AssetCardProps> = ({
) : isWord ? ( ) : isWord ? (
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between"> <div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
{/* Document Margins decorative elements */} {/* Document Margins decorative elements */}
<div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" /> <div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" />
{/* Word Header */} {/* Word Header */}
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5"> <div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span> <span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-slate-800">DOCX</span> <span className="font-extrabold text-indigo-650">DOCX</span>
</div> </div>
{/* Page Title & Body Text Mockup */} {/* Page Title & Body Text Mockup */}
@ -623,14 +568,17 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
<div className="space-y-1 mb-4 flex-grow flex flex-col"> <div className="space-y-1 mb-4 flex-grow flex flex-col">
<div className="flex items-center gap-1.5 mb-2">
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
{asset.categoryId || 'General'}
</div>
{!asset.isDownloadable && ( {!asset.isDownloadable && (
<div className="flex items-center gap-1.5 mb-2 overflow-hidden"> <div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200 shrink-0">
<Lock className="w-2.5 h-2.5" /> <Lock className="w-2.5 h-2.5" />
<span>Strict View Only</span> <span>Strict View Only</span>
</div> </div>
</div>
)} )}
</div>
<h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-900 transition-colors" title={asset.title}> <h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-900 transition-colors" title={asset.title}>
{asset.title} {asset.title}

View File

@ -113,88 +113,6 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
</div> </div>
)} )}
{/* Taxonomy Metadata Section */}
<div className="space-y-3 pt-3 border-t border-ink-200">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">
Taxonomy & Enterprise Classification
</h4>
{/* Verticals */}
{asset.verticals && asset.verticals.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Industry Verticals / Domains:</span>
<div className="flex flex-wrap gap-1.5">
{asset.verticals.map(v => (
<span
key={v.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold border"
style={{
backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6',
}}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{v.name}
</span>
))}
</div>
</div>
)}
{/* Tech Stacks */}
{asset.techStacks && asset.techStacks.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Tech Stack & Capabilities:</span>
<div className="flex flex-wrap gap-1.5">
{asset.techStacks.map(t => (
<span
key={t.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-slate-700 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
{t.name}
</span>
))}
</div>
</div>
)}
{/* Engagement Types */}
{asset.engagementTypes && asset.engagementTypes.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Engagement Type:</span>
<div className="flex flex-wrap gap-1.5">
{asset.engagementTypes.map(e => (
<span
key={e.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
>
{e.name}
</span>
))}
</div>
</div>
)}
{/* Compliance Standards */}
{asset.complianceStandards && asset.complianceStandards.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Compliance & Governance:</span>
<div className="flex flex-wrap gap-1.5">
{asset.complianceStandards.map(c => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
>
🛡 {c.name}
</span>
))}
</div>
</div>
)}
</div>
{asset.tags.length > 0 && ( {asset.tags.length > 0 && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4>

View File

@ -1,224 +0,0 @@
import React from 'react';
import { Download, ExternalLink, FileText, Globe, Sparkles } from 'lucide-react';
import type { Asset } from '../../../types/assets';
interface AssetTableViewProps {
assets: Asset[];
selectedIds: string[];
recommendedIds?: string[];
onToggleSelect: (id: string, event: React.MouseEvent) => void;
onSelectAll: () => void;
onOpenAsset: (asset: Asset) => void;
onRequestDownload: (id: string) => void;
userRole?: string;
}
export const AssetTableView: React.FC<AssetTableViewProps> = ({
assets,
selectedIds,
recommendedIds = [],
onToggleSelect,
onSelectAll,
onOpenAsset,
onRequestDownload,
}) => {
const allSelected = assets.length > 0 && selectedIds.length === assets.length;
return (
<div className="w-full bg-white dark:bg-slate-900/90 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden transition-all">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
<th className="py-3 px-4 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 focus:ring-slate-400 cursor-pointer"
/>
</th>
<th className="py-3 px-4 min-w-[280px]">Asset Name & Domain</th>
<th className="py-3 px-4 min-w-[140px]">Vertical / Industry</th>
<th className="py-3 px-4 min-w-[130px]">Type / Format</th>
<th className="py-3 px-4 min-w-[120px]">Subcategory</th>
<th className="py-3 px-4 min-w-[120px]">Created</th>
<th className="py-3 px-4 text-right min-w-[110px]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/60 text-xs">
{assets.map((asset) => {
const isSelected = selectedIds.includes(asset.id);
const isRecommended = recommendedIds.includes(asset.id);
return (
<tr
id={`asset-card-${asset.id}`}
key={asset.id}
onClick={(e) => onToggleSelect(asset.id, e)}
className={`group hover:bg-slate-100/70 dark:hover:bg-slate-800/60 transition-colors cursor-pointer ${
isSelected
? 'bg-slate-100 dark:bg-slate-800/80 font-medium'
: isRecommended
? 'bg-amber-500/[0.03] dark:bg-amber-500/[0.04]'
: ''
}`}
>
{/* Selection Checkbox */}
<td className="py-3 px-4" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onToggleSelect(asset.id, e as any)}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 focus:ring-slate-400 cursor-pointer"
/>
</td>
{/* Title & Description & Recommended Badge */}
<td className="py-3 px-4 min-w-[280px]">
<div className="flex items-start gap-2.5">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 shrink-0 mt-0.5">
{asset.type === 'url' || asset.type === 'case_study' ? (
<Globe className="w-4 h-4 text-slate-500 dark:text-slate-400" />
) : (
<FileText className="w-4 h-4 text-amber-500" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-slate-900 dark:text-slate-100 group-hover:text-slate-900 dark:group-hover:text-white transition-colors truncate">
{asset.title}
</span>
{isRecommended && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 text-[9px] font-extrabold uppercase tracking-wider shrink-0">
<Sparkles className="w-2.5 h-2.5 fill-slate-950" />
<span>Recommended</span>
</span>
)}
</div>
{asset.description && (
<div className="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-1 mt-0.5">
{asset.description}
</div>
)}
{asset.tags && asset.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{asset.tags.slice(0, 3).map(t => (
<span key={t} className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">
#{t}
</span>
))}
{asset.tags.length > 3 && (
<span className="text-[9px] text-slate-400">+{asset.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
</td>
{/* Taxonomy Classification */}
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1 max-w-xs">
{asset.verticals && asset.verticals.map(v => (
<span
key={v.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border"
style={{
backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6',
}}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{v.name}
</span>
))}
{asset.techStacks && asset.techStacks.map(t => (
<span
key={t.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
{t.name}
</span>
))}
{asset.engagementTypes && asset.engagementTypes.map(e => (
<span
key={e.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
>
{e.name}
</span>
))}
{asset.complianceStandards && asset.complianceStandards.map(c => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
>
{c.name}
</span>
))}
{(!asset.verticals || asset.verticals.length === 0) &&
(!asset.techStacks || asset.techStacks.length === 0) &&
(!asset.engagementTypes || asset.engagementTypes.length === 0) &&
(!asset.complianceStandards || asset.complianceStandards.length === 0) && (
<span className="text-slate-400 italic text-[11px]">General</span>
)}
</div>
</td>
{/* Type */}
<td className="py-3 px-4">
<span className="inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 capitalize">
{asset.contentType ? asset.contentType.replace('_', ' ') : asset.type}
</span>
</td>
{/* Subcategory */}
<td className="py-3 px-4 text-slate-700 dark:text-slate-300 font-semibold">
{asset.subcategory || asset.categoryId || '—'}
</td>
{/* Created Date */}
<td className="py-3 px-4 text-slate-500 text-[11px]">
{new Date(asset.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</td>
{/* Actions */}
<td className="py-3 px-4 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onOpenAsset(asset)}
className="p-1.5 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="View Asset"
>
<ExternalLink className="w-4 h-4" />
</button>
{asset.isDownloadable && (
<button
onClick={() => onRequestDownload(asset.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="Download Asset"
>
<Download className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
import { axiosInstance } from '../../../services/axios'; import { axiosInstance } from '../../../services/axios';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth'; import type { User } from '../../../types/auth';
@ -24,7 +23,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
user, user,
onDownload onDownload
}) => { }) => {
const navigate = useNavigate();
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isLoadingText, setIsLoadingText] = useState(false); const [isLoadingText, setIsLoadingText] = useState(false);
const [textPreviewContent, setTextPreviewContent] = useState(''); const [textPreviewContent, setTextPreviewContent] = useState('');
@ -33,29 +31,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
const [isLoadingDoc, setIsLoadingDoc] = useState(false); const [isLoadingDoc, setIsLoadingDoc] = useState(false);
const [docLoadError, setDocLoadError] = useState(false); const [docLoadError, setDocLoadError] = useState(false);
const wordContainerRef = React.useRef<HTMLDivElement>(null); const wordContainerRef = React.useRef<HTMLDivElement>(null);
const extractYouTubeVideoId = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
const getFullAssetUrl = (url: string) => { const getFullAssetUrl = (url: string) => {
let resolvedUrl = url; let resolvedUrl = url;
@ -164,8 +139,8 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
); );
}; };
const isWord = asset ? (((asset.type || '').includes('word') || (asset.url || '').toLowerCase().endsWith('.docx') || (asset.url || '').toLowerCase().endsWith('.doc'))) : false; const isWord = asset ? (asset.type.includes('word') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc')) : false;
const isSpreadsheet = asset ? (((asset.type || '').includes('sheet') || (asset.url || '').toLowerCase().endsWith('.xlsx') || (asset.url || '').toLowerCase().endsWith('.xls') || (asset.url || '').toLowerCase().endsWith('.csv'))) : false; const isSpreadsheet = asset ? (asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv')) : false;
useEffect(() => { useEffect(() => {
if (isOpen && asset) { if (isOpen && asset) {
@ -263,8 +238,8 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
} }
}, [docBlob, isWord]); }, [docBlob, isWord]);
const formatBytes = (bytes?: number, decimals = 2) => { const formatBytes = (bytes: number, decimals = 2) => {
if (!bytes || isNaN(bytes) || bytes === 0) return ''; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ['Bytes', 'KB', 'MB', 'GB'];
@ -277,29 +252,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
onClose(); onClose();
}; };
const handleLocateInCatalog = () => {
onClose();
if (!asset) return;
const typeLower = (asset.type || '').toLowerCase();
const titleLower = (asset.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
let targetBasePath = '';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
targetBasePath = isCurrentAdmin ? '/admin/showcase' : '/client/showcase';
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
targetBasePath = isCurrentAdmin ? '/admin/legal' : '/client/agreements';
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
targetBasePath = isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem';
} else {
targetBasePath = isCurrentAdmin ? '/admin/assets' : '/client/assets';
}
const fullUrl = `${targetBasePath}?highlight=${asset.id}`;
navigate(fullUrl, { state: { highlightAssetId: asset.id } });
};
return ( return (
<Modal <Modal
isOpen={isOpen && !!asset} isOpen={isOpen && !!asset}
@ -309,7 +261,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="text-left"> <div className="text-left">
<span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span> <span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
<span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal"> <span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
{asset?.type === 'url' ? 'External Web Link' : asset?.size ? `${asset.type}${formatBytes(asset.size)}` : asset?.type} {asset?.type === 'url' ? 'External Web Link' : `${asset?.type}${asset ? formatBytes(asset.size) : ''}`}
</span> </span>
</div> </div>
<button <button
@ -360,19 +312,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</Button> </Button>
)} )}
{asset && ( {asset && asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button
onClick={handleLocateInCatalog}
variant="secondary"
size="sm"
className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10"
>
<Compass className="w-4 h-4" />
<span>Locate in Portal Catalog</span>
</Button>
)}
{asset && asset.type !== 'url' && asset.type !== 'case_study' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button <Button
onClick={() => onDownload(asset)} onClick={() => onDownload(asset)}
variant="primary" variant="primary"
@ -446,12 +386,12 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div> </div>
{/* Browser Content */} {/* Browser Content */}
<div className="flex-1 bg-slate-950 p-6 flex flex-col justify-between relative overflow-hidden"> <div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-6 flex flex-col justify-between relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px]" /> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px]" />
<div className="space-y-3 relative z-10 text-left"> <div className="space-y-3 relative z-10 text-left">
<div className="flex justify-between items-center bg-white/[0.02] border border-white/5 rounded-lg px-3 py-1.5"> <div className="flex justify-between items-center bg-white/[0.02] border border-white/5 rounded-lg px-3 py-1.5">
<div className="w-16 h-2 bg-slate-700 rounded" /> <div className="w-16 h-2 bg-indigo-400/40 rounded" />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="w-6 h-1.5 bg-white/20 rounded" /> <div className="w-6 h-1.5 bg-white/20 rounded" />
<div className="w-6 h-1.5 bg-white/20 rounded" /> <div className="w-6 h-1.5 bg-white/20 rounded" />
@ -459,14 +399,14 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div> </div>
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="h-5 bg-slate-700 rounded-lg w-5/6" /> <div className="h-5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded-lg w-5/6" />
<div className="h-2 bg-white/20 rounded w-11/12" /> <div className="h-2 bg-white/20 rounded w-11/12" />
<div className="h-2 bg-white/15 rounded w-3/4" /> <div className="h-2 bg-white/15 rounded w-3/4" />
</div> </div>
</div> </div>
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none"> <div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md"> <div className="w-10 h-10 rounded-lg bg-indigo-650 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md shadow-indigo-600/20">
{(() => { {(() => {
try { try {
return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase(); return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase();
@ -497,7 +437,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="flex-1 bg-ink-0 border border-ink-200 rounded-2xl p-6 md:p-8 shadow-lg max-w-lg w-full flex flex-col justify-between min-h-[350px]"> <div className="flex-1 bg-ink-0 border border-ink-200 rounded-2xl p-6 md:p-8 shadow-lg max-w-lg w-full flex flex-col justify-between min-h-[350px]">
<div className="space-y-4 text-left"> <div className="space-y-4 text-left">
<div> <div>
<span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-slate-800 bg-slate-100 border border-slate-200"> <span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-indigo-650 bg-indigo-50 border border-indigo-100">
External Portal Link External Portal Link
</span> </span>
<h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3> <h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3>
@ -555,61 +495,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div> </div>
</div> </div>
</div> </div>
) : asset.type === 'case_study' || asset.type.includes('video') || asset.type === 'showcase' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-emerald-600" />
<span className="font-sans">Executive Case Study & Video Showcase</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
</div>
<div className="flex-1 overflow-auto p-6 md:p-8 flex flex-col items-center justify-start gap-6 bg-ink-50">
{asset.url && (asset.url.includes('youtube.com') || asset.url.includes('youtu.be')) ? (
<div className="w-full max-w-3xl aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-ink-300 shrink-0">
<iframe
src={`https://www.youtube-nocookie.com/embed/${extractYouTubeVideoId(asset.url)}?autoplay=1&rel=0`}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<div className="w-full max-w-3xl p-6 bg-slate-900 text-white rounded-2xl shadow-xl border border-slate-800 space-y-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-[10px] font-extrabold uppercase bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Verified Showcase Reel
</span>
</div>
<h2 className="text-lg font-bold">{asset.title}</h2>
{asset.description && <p className="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">{asset.description}</p>}
</div>
)}
<div className="w-full max-w-3xl bg-ink-0 border border-ink-200 rounded-2xl p-6 shadow-md space-y-4 text-left">
<h3 className="text-base font-extrabold text-ink-900">{asset.title}</h3>
{asset.problemStatement && (
<div className="space-y-1">
<span className="text-xs font-bold text-red-600 uppercase tracking-wider">Problem Statement</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.problemStatement}</p>
</div>
)}
{asset.solution && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Implemented Technical Solution</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.solution}</p>
</div>
)}
{asset.description && !asset.problemStatement && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-ink-600 uppercase tracking-wider">Executive Overview</span>
<p className="text-xs text-ink-700 leading-relaxed whitespace-pre-wrap">{asset.description}</p>
</div>
)}
</div>
</div>
</div>
) : asset.type.includes('pdf') ? ( ) : asset.type.includes('pdf') ? (
<div className="w-full h-full flex flex-col min-h-0"> <div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
@ -806,100 +691,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100" className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/> />
</div> </div>
) : (asset.type === 'case_study' || asset.problemStatement || asset.solution) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-4xl mx-auto w-full space-y-6">
{asset.thumbnailUrl && (
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
<img src={asset.thumbnailUrl} alt={asset.title} className="w-full h-full object-cover" />
</div>
)}
<div className="border-b border-ink-200 pb-4">
<div className="flex items-center gap-2 mb-2">
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-600 border border-emerald-500/20">
Executive Case Study Showcase
</span>
{asset.type && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-ink-100 text-ink-700 capitalize">
{asset.type}
</span>
)}
</div>
<h1 className="text-xl sm:text-2xl font-extrabold text-ink-900 leading-tight">{asset.title}</h1>
{asset.description && <p className="text-xs text-ink-600 mt-2 leading-relaxed">{asset.description}</p>}
</div>
{/* Problem Statement Block */}
{asset.problemStatement && (
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Problem Statement & Challenge
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.problemStatement} />
</div>
</div>
)}
{/* Proposed Solution Block */}
{asset.solution && (
<div className="p-4 rounded-xl bg-emerald-500/5 border border-emerald-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-500" />
Implemented Technical Solution
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.solution} />
</div>
</div>
)}
{asset.url && asset.url.startsWith('http') && (
<div className="pt-2 flex justify-end">
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-ink-900 text-ink-0 hover:bg-ink-800 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Visit Showcase Webpage
</a>
</div>
)}
</div>
</div>
) : asset.type === 'url' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-3xl mx-auto w-full space-y-6 text-center flex flex-col items-center justify-center h-full">
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600">
<Globe className="w-8 h-8" />
</div>
<div>
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500/10 text-blue-600 border border-blue-500/20">
External Web Resource
</span>
<h2 className="text-xl font-extrabold text-ink-900 mt-3">{asset.title}</h2>
{asset.description && <p className="text-xs text-ink-600 mt-2 max-w-lg mx-auto">{asset.description}</p>}
</div>
<div className="p-4 bg-ink-50 border border-ink-200 rounded-xl w-full max-w-md text-left">
<p className="text-[10px] font-mono text-ink-500 truncate">{asset.url}</p>
</div>
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-md transition-all cursor-pointer"
>
<ExternalLink className="w-4 h-4" />
Launch Web Document
</a>
</div>
</div>
) : ( ) : (
<div className="text-center p-8 flex flex-col justify-center items-center"> <div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" /> <File className="w-12 h-12 text-ink-300 mb-3" />

View File

@ -65,7 +65,7 @@ export const DocxThumbnail: React.FC<DocxThumbnailProps> = ({ url, fallback }) =
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none"> <div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none">
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10"> <div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10">
<div className="w-5 h-5 rounded-full border-2 border-slate-800 border-t-transparent animate-spin" /> <div className="w-5 h-5 rounded-full border-2 border-indigo-600 border-t-transparent animate-spin" />
</div> </div>
)} )}
<div <div

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { updateAsset, getTaxonomyMeta } from '../../../services/assets-api'; import { updateAsset } from '../../../services/assets-api';
import type { Asset, TaxonomyMeta } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import Modal from '../../../components/ui/Modal'; import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast'; import { useToast } from '../../../hooks/use-toast';
@ -19,13 +19,6 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast(); const { success, error } = useToast();
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [editVerticalIds, setEditVerticalIds] = useState<string[]>([]);
const [editTechStackIds, setEditTechStackIds] = useState<string[]>([]);
const [editEngagementTypeIds, setEditEngagementTypeIds] = useState<string[]>([]);
const [editComplianceIds, setEditComplianceIds] = useState<string[]>([]);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing'); const [editCategory, setEditCategory] = useState('Marketing');
@ -35,10 +28,6 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
const [editIsDownloadable, setEditIsDownloadable] = useState(true); const [editIsDownloadable, setEditIsDownloadable] = useState(true);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
setEditTitle(asset.title); setEditTitle(asset.title);
@ -48,18 +37,9 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
setEditTags(asset.tags.join(', ')); setEditTags(asset.tags.join(', '));
setEditGithubUrl(asset.githubUrl || ''); setEditGithubUrl(asset.githubUrl || '');
setEditIsDownloadable(asset.isDownloadable); setEditIsDownloadable(asset.isDownloadable);
setEditVerticalIds(asset.verticals ? asset.verticals.map(v => v.id) : []);
setEditTechStackIds(asset.techStacks ? asset.techStacks.map(t => t.id) : []);
setEditEngagementTypeIds(asset.engagementTypes ? asset.engagementTypes.map(e => e.id) : []);
setEditComplianceIds(asset.complianceStandards ? asset.complianceStandards.map(c => c.id) : []);
} }
}, [asset]); }, [asset]);
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
const handleEditSubmit = async (e: React.FormEvent) => { const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset) return; if (!asset) return;
@ -71,10 +51,6 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
description: editDescription, description: editDescription,
categoryId: editCategory, categoryId: editCategory,
subcategory: editSubcategory, subcategory: editSubcategory,
verticalIds: editVerticalIds,
techStackIds: editTechStackIds,
engagementTypeIds: editEngagementTypeIds,
complianceIds: editComplianceIds,
tags: editTags.split(',').map(t => t.trim()).filter(Boolean), tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
githubUrl: editGithubUrl, githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable, isDownloadable: editIsDownloadable,
@ -131,130 +107,30 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
/> />
</div> </div>
{/* 4-Group Taxonomy Demarcation Selection */} <div className="grid grid-cols-2 gap-4">
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Taxonomy Classification (Strict Admin-Managed)
</h4>
{/* Group 1: Industry Verticals */}
<div> <div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block"> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
1. Industry Verticals / Domains <select
</label> value={editCategory}
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800"> onChange={(e) => setEditCategory(e.target.value)}
{(taxonomyMeta?.verticals || []).map(v => { className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
const isSelected = editVerticalIds.includes(v.id);
return (
<button
key={v.id}
type="button"
onClick={() => setEditVerticalIds(toggleSelection(editVerticalIds, v.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
> >
{v.name} <option value="Marketing">Marketing</option>
</button> <option value="Presentations">Presentations</option>
); <option value="Branding">Branding</option>
})} <option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div> </div>
</div>
{/* Group 2: Tech Stack */}
<div> <div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block"> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
2. Technology Stack & Capabilities <input
</label> type="text"
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800"> value={editSubcategory}
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => { onChange={(e) => setEditSubcategory(e.target.value)}
const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks')); placeholder="e.g. Slide Deck"
if (items.length === 0) return null; className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
return ( />
<div key={catName} className="space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{catName}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map(t => {
const isSelected = editTechStackIds.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setEditTechStackIds(toggleSelection(editTechStackIds, t.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Group 3 & Group 4 Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Group 3: Engagement Type */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
3. Engagement Type
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.engagementTypes || []).map(e => {
const isSelected = editEngagementTypeIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setEditEngagementTypeIds(toggleSelection(editEngagementTypeIds, e.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{e.name}
</button>
);
})}
</div>
</div>
{/* Group 4: Compliance Standards */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
4. Compliance & Governance
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.complianceStandards || []).map(c => {
const isSelected = editComplianceIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => setEditComplianceIds(toggleSelection(editComplianceIds, c.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
</div> </div>
</div> </div>

View File

@ -1,451 +0,0 @@
import React, { useState } from 'react';
import { X, Filter, Search, RotateCcw, Check, Shield, Cpu, Activity, Landmark, FileCheck, Zap, Leaf, GraduationCap, Factory, Car, ShoppingBag, Link as LinkIcon, FileText, LayoutGrid, Newspaper, Briefcase, PlayCircle, Code, Compass, HelpCircle } from 'lucide-react';
import type { TaxonomyMeta, AssetQueryFilters } from '../../../types/assets';
interface FilterDrawerProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
filters: AssetQueryFilters;
onChangeFilters: (newFilters: AssetQueryFilters) => void;
onClearAll: () => void;
}
const VERTICAL_ICONS: Record<string, React.ReactNode> = {
Shield: <Shield className="w-4 h-4" />,
Cpu: <Cpu className="w-4 h-4" />,
Activity: <Activity className="w-4 h-4" />,
Landmark: <Landmark className="w-4 h-4" />,
FileCheck: <FileCheck className="w-4 h-4" />,
Zap: <Zap className="w-4 h-4" />,
Leaf: <Leaf className="w-4 h-4" />,
GraduationCap: <GraduationCap className="w-4 h-4" />,
Factory: <Factory className="w-4 h-4" />,
Car: <Car className="w-4 h-4" />,
ShoppingBag: <ShoppingBag className="w-4 h-4" />,
Link: <LinkIcon className="w-4 h-4" />,
};
const CONTENT_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
case_study: { label: 'Case Studies', icon: <FileText className="w-4 h-4" />, color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
showcase: { label: 'Showcases', icon: <LayoutGrid className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border-slate-500/20' },
newsletter: { label: 'Newsletters', icon: <Newspaper className="w-4 h-4" />, color: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
portfolio: { label: 'Portfolios & Decks', icon: <Briefcase className="w-4 h-4" />, color: 'bg-purple-500/10 text-purple-600 border-purple-500/20' },
mvp: { label: 'Live MVPs', icon: <PlayCircle className="w-4 h-4" />, color: 'bg-sky-500/10 text-sky-600 border-sky-500/20' },
document: { label: 'Documents & PDFs', icon: <FileText className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-600 border-slate-500/20' },
workflow: { label: 'Workflow Automations', icon: <Code className="w-4 h-4" />, color: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
use_case: { label: 'Technical Use Cases', icon: <Compass className="w-4 h-4" />, color: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
test_drive: { label: 'Test Drive Resources', icon: <HelpCircle className="w-4 h-4" />, color: 'bg-rose-500/10 text-rose-600 border-rose-500/20' },
};
export const FilterDrawer: React.FC<FilterDrawerProps> = ({
isOpen,
onClose,
meta,
filters,
onChangeFilters,
onClearAll,
}) => {
const [filterQuery, setFilterQuery] = useState('');
if (!isOpen) return null;
const toggleArrayItem = (current: string[] | undefined, item: string): string[] => {
const arr = current || [];
return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item];
};
const activeCount =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const filteredVerticals = (meta?.verticals || []).filter(v =>
v.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredSubcategories = (meta?.subcategories || []).filter(s =>
s.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredTags = (meta?.tags || []).filter(t =>
t.name.toLowerCase().includes(filterQuery.toLowerCase())
);
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity duration-300 animate-fadeIn"
onClick={onClose}
/>
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className="w-screen max-w-md bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-slate-800 flex flex-col transform transition-transform duration-300">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100">
<Filter className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
Asset Discovery Filters
{activeCount > 0 && (
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
{activeCount}
</span>
)}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Narrow catalog by domains, types & tags
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Search inside Filters */}
<div className="p-4 border-b border-slate-100 dark:border-slate-800 bg-slate-50/30 dark:bg-slate-900/30">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search filter keywords..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar">
{/* 1. Industry Verticals (Group 1) */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>1. Industry Verticals</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({filteredVerticals.length})
</span>
</h3>
<div className="space-y-1.5">
{filteredVerticals.map(vertical => {
const isSelected = filters.verticalIds?.includes(vertical.id);
const iconNode = vertical.icon ? VERTICAL_ICONS[vertical.icon] : null;
return (
<button
key={vertical.id}
onClick={() =>
onChangeFilters({
...filters,
verticalIds: toggleArrayItem(filters.verticalIds, vertical.id),
})
}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 font-bold shadow-sm'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: vertical.color || '#3b82f6' }}
/>
<span className="shrink-0 opacity-80">
{iconNode}
</span>
<span className="truncate">{vertical.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${
isSelected ? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200' : 'bg-slate-100 dark:bg-slate-800 text-slate-500'
}`}>
{vertical._count?.assets ?? 0}
</span>
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
isSelected
? 'bg-amber-500 border-amber-500 text-slate-950'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
}`}
>
{isSelected && <Check className="w-3 h-3 stroke-[3]" />}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* 2. Technology Stack (Group 2) */}
{(meta?.techStacks || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>2. Technology Stack</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({(meta?.techStacks || []).length})
</span>
</h3>
<div className="space-y-3">
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(cat => {
const groupItems = (meta?.techStacks || []).filter(t => t.category === cat);
if (groupItems.length === 0) return null;
return (
<div key={cat} className="space-y-1">
<div className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 px-1">
{cat}
</div>
<div className="flex flex-wrap gap-1.5">
{groupItems.map(tech => {
const isSelected = filters.techStackIds?.includes(tech.id);
return (
<button
key={tech.id}
onClick={() =>
onChangeFilters({
...filters,
techStackIds: toggleArrayItem(filters.techStackIds, tech.id),
})
}
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-all flex items-center gap-1.5 ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-bold'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: tech.color || '#64748b' }} />
{tech.name}
<span className="text-[10px] opacity-70">({tech._count?.assets ?? 0})</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
{/* 3. Engagement Type (Group 3) */}
{(meta?.engagementTypes || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>3. Engagement Type</span>
</h3>
<div className="grid grid-cols-2 gap-2">
{(meta?.engagementTypes || []).map(eng => {
const isSelected = filters.engagementTypeIds?.includes(eng.id);
return (
<button
key={eng.id}
onClick={() =>
onChangeFilters({
...filters,
engagementTypeIds: toggleArrayItem(filters.engagementTypeIds, eng.id),
})
}
className={`p-2.5 rounded-lg border text-left text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="font-bold flex items-center justify-between">
<span>{eng.name}</span>
<span className="text-[10px] opacity-70 font-mono">({eng._count?.assets ?? 0})</span>
</div>
{eng.description && (
<div className="text-[10px] opacity-75 mt-0.5 line-clamp-1">{eng.description}</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* 4. Compliance & Regulatory (Group 4) */}
{(meta?.complianceStandards || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>4. Compliance & Governance</span>
</h3>
<div className="flex flex-wrap gap-1.5">
{(meta?.complianceStandards || []).map(comp => {
const isSelected = filters.complianceIds?.includes(comp.id);
return (
<button
key={comp.id}
onClick={() =>
onChangeFilters({
...filters,
complianceIds: toggleArrayItem(filters.complianceIds, comp.id),
})
}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all flex items-center gap-2 ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 shadow-sm'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{comp.name}
<span className="text-[10px] opacity-70 font-mono">({comp._count?.assets ?? 0})</span>
</button>
);
})}
</div>
</div>
)}
{/* 5. Content Types */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Content Types
</h3>
<div className="grid grid-cols-1 gap-2">
{(meta?.contentTypes || []).map(ct => {
const isSelected = filters.contentTypes?.includes(ct.name);
const config = CONTENT_TYPE_CONFIG[ct.name] || {
label: ct.name,
icon: <FileText className="w-4 h-4" />,
color: 'bg-slate-100 text-slate-700',
};
return (
<button
key={ct.name}
onClick={() =>
onChangeFilters({
...filters,
contentTypes: toggleArrayItem(filters.contentTypes, ct.name),
})
}
className={`flex items-center justify-between p-2.5 rounded-lg border text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2.5">
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${
isSelected
? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
}`}
>
{ct.count}
</span>
</button>
);
})}
</div>
</div>
{/* 6. Subcategories */}
{filteredSubcategories.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Subcategories
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredSubcategories.map(sub => {
const isSelected = filters.subcategories?.includes(sub.name);
return (
<button
key={sub.name}
onClick={() =>
onChangeFilters({
...filters,
subcategories: toggleArrayItem(filters.subcategories, sub.name),
})
}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-semibold'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
{sub.name} <span className="opacity-60 text-[10px]">({sub.count})</span>
</button>
);
})}
</div>
</div>
)}
{/* 7. Tag Cloud */}
{filteredTags.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Tags & Keywords
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(tag => {
const isSelected = filters.tags?.includes(tag.name);
return (
<button
key={tag.name}
onClick={() =>
onChangeFilters({
...filters,
tags: toggleArrayItem(filters.tags, tag.name),
})
}
className={`px-2 py-0.5 rounded-full text-xs font-mono transition-all ${
isSelected
? 'bg-slate-900 text-white font-semibold dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
#{tag.name} <span className="opacity-60 text-[10px]">({tag.count})</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/80 dark:bg-slate-900/80 flex items-center justify-between gap-3">
<button
onClick={onClearAll}
disabled={activeCount === 0}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Clear All ({activeCount})
</button>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md transition-all"
>
Apply Filters
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -32,8 +32,6 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
const [groups, setGroups] = useState<AssetGroup[]>([]); const [groups, setGroups] = useState<AssetGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>(''); const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const [shareMode, setShareMode] = useState<'ALL' | 'SELECTED'>('ALL');
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
getAssetGroups().then(setGroups).catch(console.error); getAssetGroups().then(setGroups).catch(console.error);
@ -43,22 +41,16 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
const existingShares = asset.sharedWith?.map(s => ({ setSharesList(
asset.sharedWith?.map(s => ({
organizationId: s.organizationId, organizationId: s.organizationId,
userId: s.userId userId: s.userId
})) || []; })) || []
setSharesList(existingShares);
// Pre-select mode: if shared with all orgs, set ALL, otherwise SELECTED
const isSharedWithAll = organizations.length > 0 && organizations.every(org =>
existingShares.some(s => s.organizationId === org.id && s.userId === null)
); );
setShareMode(isSharedWithAll ? 'ALL' : (existingShares.length === 0 ? 'ALL' : 'SELECTED'));
} else { } else {
setSharesList([]); setSharesList([]);
setShareMode('ALL');
} }
}, [asset, isOpen, organizations]); }, [asset, isOpen]);
const isOrgSharedEntirely = (orgId: string) => { const isOrgSharedEntirely = (orgId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === null); return sharesList.some(s => s.organizationId === orgId && s.userId === null);
@ -98,16 +90,12 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
setIsSavingShare(true); setIsSavingShare(true);
try { try {
const targetShares = shareMode === 'ALL'
? organizations.map(org => ({ organizationId: org.id, userId: null }))
: sharesList;
if (asset) { if (asset) {
await updateAsset(asset.id, { await updateAsset(asset.id, {
shares: targetShares shares: sharesList
}); });
} else if (assetIds && assetIds.length > 0) { } else if (assetIds && assetIds.length > 0) {
await bulkShareAssets(assetIds, targetShares); await bulkShareAssets(assetIds, sharesList);
} }
if (selectedGroupId) { if (selectedGroupId) {
@ -117,10 +105,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
} }
} }
success('Share permissions updated', shareMode === 'ALL' success('Share permissions updated', 'The asset visibility and group membership settings have been updated.');
? 'The asset has been shared with ALL partner organizations under the "All Assets" catalog tab.'
: 'The asset visibility and group access settings have been updated.'
);
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
@ -135,7 +120,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
<Modal <Modal
isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))} isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))}
onClose={onClose} onClose={onClose}
title="Share & Partner Access Settings" title="Share Settings"
subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`} subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`}
size="md" size="md"
footer={ footer={
@ -162,50 +147,8 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
> >
{(asset || (assetIds && assetIds.length > 0)) && ( {(asset || (assetIds && assetIds.length > 0)) && (
<form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4"> <form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
<p className="text-xs text-ink-600 leading-relaxed font-sans">
{/* Share Scope Selector Pill */} Select organizations or expand to specify exact users that can access this asset:
<div className="space-y-2">
<label className="text-[10px] font-extrabold uppercase tracking-wider text-ink-500 block font-sans">
Sharing Scope (Admin Access Control)
</label>
<div className="grid grid-cols-2 gap-2 p-1 bg-ink-100 rounded-xl border border-ink-200">
<button
type="button"
onClick={() => setShareMode('ALL')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareMode === 'ALL'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'text-ink-600 hover:text-ink-900 hover:bg-ink-200/60'
}`}
>
<span>🌐 Share with ALL Partners</span>
</button>
<button
type="button"
onClick={() => setShareMode('SELECTED')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareMode === 'SELECTED'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'text-ink-600 hover:text-ink-900 hover:bg-ink-200/60'
}`}
>
<span>👥 Selected Partners Only</span>
</button>
</div>
</div>
{shareMode === 'ALL' ? (
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-xl text-xs text-emerald-800 dark:text-emerald-300 font-sans space-y-1">
<span className="font-extrabold block">🌐 Global Access Mode Enabled</span>
<p className="leading-relaxed text-[11px]">
This asset will be automatically accessible to <strong>ALL registered partner organizations</strong> and visible under the "All Assets" catalog tab.
</p>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-ink-600 leading-relaxed font-sans font-medium">
Select specific partner organizations or expand to specify exact users:
</p> </p>
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin"> <div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
@ -286,8 +229,6 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
}) })
)} )}
</div> </div>
</div>
)}
{groups.length > 0 && ( {groups.length > 0 && (
<div className="pt-2 border-t border-ink-200"> <div className="pt-2 border-t border-ink-200">

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, UploadCloud, Eye, FileText, File, Sparkles, Globe, Users } from 'lucide-react'; import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta, getOrganizations } from '../../../services/assets-api'; import { uploadAsset, scrapeCaseStudy } from '../../../services/assets-api';
import type { TaxonomyMeta, Organization } from '../../../types/assets';
import Modal from '../../../components/ui/Modal'; import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast'; import { useToast } from '../../../hooks/use-toast';
@ -18,16 +17,6 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast(); const { success, error } = useToast();
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [shareScope, setShareScope] = useState<'ALL' | 'SELECTED'>('ALL');
const [selectedOrgIds, setSelectedOrgIds] = useState<string[]>([]);
const [selectedVerticalIds, setSelectedVerticalIds] = useState<string[]>([]);
const [selectedTechStackIds, setSelectedTechStackIds] = useState<string[]>([]);
const [selectedEngagementTypeIds, setSelectedEngagementTypeIds] = useState<string[]>([]);
const [selectedComplianceIds, setSelectedComplianceIds] = useState<string[]>([]);
const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file'); const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@ -45,14 +34,8 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
const [uploadTags, setUploadTags] = useState(''); const [uploadTags, setUploadTags] = useState('');
const [uploadGithubUrl, setUploadGithubUrl] = useState(''); const [uploadGithubUrl, setUploadGithubUrl] = useState('');
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true); const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
const [includeInKnowledgeBase, setIncludeInKnowledgeBase] = useState(true);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
getOrganizations().then(setOrganizations).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (!uploadFile) { if (!uploadFile) {
setPreviewUrl(null); setPreviewUrl(null);
@ -167,18 +150,6 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean))); formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
formData.append('githubUrl', uploadGithubUrl); formData.append('githubUrl', uploadGithubUrl);
formData.append('isDownloadable', String(uploadIsDownloadable)); formData.append('isDownloadable', String(uploadIsDownloadable));
if (selectedVerticalIds.length) {
formData.append('verticalIds', JSON.stringify(selectedVerticalIds));
}
if (selectedTechStackIds.length) {
formData.append('techStackIds', JSON.stringify(selectedTechStackIds));
}
if (selectedEngagementTypeIds.length) {
formData.append('engagementTypeIds', JSON.stringify(selectedEngagementTypeIds));
}
if (selectedComplianceIds.length) {
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
}
if (thumbnailUrl) { if (thumbnailUrl) {
formData.append('thumbnailUrl', thumbnailUrl); formData.append('thumbnailUrl', thumbnailUrl);
@ -187,14 +158,6 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('problemStatement', problemStatement); formData.append('problemStatement', problemStatement);
formData.append('solution', solution); formData.append('solution', solution);
} }
formData.append('includeInKnowledgeBase', String(includeInKnowledgeBase));
if (organizations.length > 0) {
const targetShares = shareScope === 'ALL'
? organizations.map(org => ({ organizationId: org.id, userId: null }))
: selectedOrgIds.map(orgId => ({ organizationId: orgId, userId: null }));
formData.append('shares', JSON.stringify(targetShares));
}
try { try {
await uploadAsset(formData); await uploadAsset(formData);
@ -210,10 +173,6 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
setUploadSubcategory(''); setUploadSubcategory('');
setUploadTags(''); setUploadTags('');
setUploadGithubUrl(''); setUploadGithubUrl('');
setSelectedVerticalIds([]);
setSelectedTechStackIds([]);
setSelectedEngagementTypeIds([]);
setSelectedComplianceIds([]);
setUploadIsDownloadable(true); setUploadIsDownloadable(true);
success('Asset published successfully', 'The asset has been added to the catalog.'); success('Asset published successfully', 'The asset has been added to the catalog.');
onSuccess(); onSuccess();
@ -226,10 +185,6 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
} }
}; };
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
return ( return (
<> <>
<Modal <Modal
@ -500,130 +455,30 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
/> />
</div> </div>
{/* 4-Group Taxonomy Demarcation Selection */} <div className="grid grid-cols-2 gap-4">
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Taxonomy Classification (Strict Admin-Managed)
</h4>
{/* Group 1: Industry Verticals */}
<div> <div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block"> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
1. Industry Verticals / Domains <select
</label> value={uploadCategory}
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800"> onChange={(e) => setUploadCategory(e.target.value)}
{(taxonomyMeta?.verticals || []).map(v => { className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
const isSelected = selectedVerticalIds.includes(v.id);
return (
<button
key={v.id}
type="button"
onClick={() => setSelectedVerticalIds(toggleSelection(selectedVerticalIds, v.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
> >
{v.name} <option value="Marketing">Marketing</option>
</button> <option value="Presentations">Presentations</option>
); <option value="Branding">Branding</option>
})} <option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div> </div>
</div>
{/* Group 2: Tech Stack */}
<div> <div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block"> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
2. Technology Stack & Capabilities <input
</label> type="text"
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800"> value={uploadSubcategory}
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => { onChange={(e) => setUploadSubcategory(e.target.value)}
const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks')); placeholder="e.g. Slide Deck"
if (items.length === 0) return null; className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
return ( />
<div key={catName} className="space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{catName}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map(t => {
const isSelected = selectedTechStackIds.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setSelectedTechStackIds(toggleSelection(selectedTechStackIds, t.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Group 3 & Group 4 Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Group 3: Engagement Type */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
3. Engagement Type
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.engagementTypes || []).map(e => {
const isSelected = selectedEngagementTypeIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setSelectedEngagementTypeIds(toggleSelection(selectedEngagementTypeIds, e.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{e.name}
</button>
);
})}
</div>
</div>
{/* Group 4: Compliance Standards */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
4. Compliance & Governance
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.complianceStandards || []).map(c => {
const isSelected = selectedComplianceIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => setSelectedComplianceIds(toggleSelection(selectedComplianceIds, c.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
</div> </div>
</div> </div>
@ -659,87 +514,6 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
</div> </div>
)} )}
<div className="flex items-start gap-3 p-3 bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-xl">
<input
type="checkbox"
id="includeInKnowledgeBase"
checked={includeInKnowledgeBase}
onChange={(e) => setIncludeInKnowledgeBase(e.target.checked)}
className="w-4 h-4 rounded border-emerald-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer mt-0.5"
/>
<div>
<label htmlFor="includeInKnowledgeBase" className="text-xs font-bold text-ink-900 dark:text-emerald-300 cursor-pointer flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-emerald-500" />
Include in AI Advisor Knowledge Base (RAG Search)
</label>
<span className="text-[10px] text-ink-500 dark:text-slate-400 block mt-0.5">
If checked, the AI Advisor indexes this content to answer partner questions with OKF standard citations.
</span>
</div>
</div>
{/* Partner Sharing & Visibility Scope */}
<div className="space-y-3 pt-3 border-t border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Partner Visibility & Access Scope (Admin Control)
</h4>
<div className="grid grid-cols-2 gap-2 p-1 bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setShareScope('ALL')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareScope === 'ALL'
? 'bg-slate-900 text-white dark:bg-blue-600 shadow-sm'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Share with ALL Partners</span>
</button>
<button
type="button"
onClick={() => setShareScope('SELECTED')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareScope === 'SELECTED'
? 'bg-slate-900 text-white dark:bg-blue-600 shadow-sm'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Selected Partners Only</span>
</button>
</div>
{shareScope === 'ALL' ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium p-2 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
🌐 This asset will be automatically shared with all registered partner organizations and accessible under the "All Assets" catalog tab.
</p>
) : (
<div className="space-y-1.5 max-h-36 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase block">Select Target Organizations:</span>
{organizations.length === 0 ? (
<p className="text-xs text-slate-400 italic">No partner organizations registered yet.</p>
) : (
organizations.map(org => {
const isSelected = selectedOrgIds.includes(org.id);
return (
<label key={org.id} className="flex items-center gap-2 text-xs font-medium text-slate-800 dark:text-slate-200 cursor-pointer py-1 select-none">
<input
type="checkbox"
checked={isSelected}
onChange={() => setSelectedOrgIds(toggleSelection(selectedOrgIds, org.id))}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-700"
/>
<span>{org.name}</span>
</label>
);
})
)}
</div>
)}
</div>
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input <input

View File

@ -40,7 +40,7 @@
--color-info: #3b82f6; --color-info: #3b82f6;
/* ── Typography ── */ /* ── Typography ── */
--font-sans: 'Poppins', 'Inter', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* ── Spacing Scale ── */ /* ── Spacing Scale ── */

View File

@ -1,11 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { Variants } from "framer-motion"; import type { Variants } from "framer-motion";
import { import { UploadCloud, Search, File, Share2, Folder, Sparkles } from "lucide-react";
UploadCloud, Search, File, Share2, Folder, Sparkles, Filter, LayoutGrid,
List, ArrowUpDown, Shield, X
} from "lucide-react";
import { useAuthStore } from "../hooks/use-auth"; import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios"; import { axiosInstance } from "../services/axios";
import { import {
@ -17,7 +13,6 @@ import {
rejectDownloadRequest, rejectDownloadRequest,
downloadAssetFile, downloadAssetFile,
getAssetGroups, getAssetGroups,
getTaxonomyMeta,
} from "../services/assets-api"; } from "../services/assets-api";
import type { AssetGroup } from "../services/assets-api"; import type { AssetGroup } from "../services/assets-api";
import { useToast } from "../hooks/use-toast"; import { useToast } from "../hooks/use-toast";
@ -31,25 +26,21 @@ import { AssetDetailsModal } from "../features/assets/components/AssetDetailsMod
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal"; import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal"; import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal"; import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal";
import { FilterDrawer } from "../features/assets/components/FilterDrawer";
import { AssetTableView } from "../features/assets/components/AssetTableView";
import { AssetAdminManagerModal } from "../features/assets/components/AssetAdminManagerModal";
import { PageHeader } from "../components/ui/PageHeader"; import { PageHeader } from "../components/ui/PageHeader";
import Button from "../components/ui/Button"; import Button from "../components/ui/Button";
import { PageLayout } from "../components/layout/PageLayout"; import { PageLayout } from "../components/layout/PageLayout";
import Modal from "../components/ui/Modal"; import Modal from "../components/ui/Modal";
// Type Definitions // Type Definitions
import type { Asset, Organization, TaxonomyMeta, AssetQueryFilters } from "../types/assets"; import type { Asset, Organization } from "../types/assets";
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.04 } }, show: { opacity: 1, transition: { staggerChildren: 0.05 } },
}; };
const itemVariants: Variants = { const itemVariants: Variants = {
hidden: { opacity: 0, y: 12, scale: 0.98 }, hidden: { opacity: 0, y: 15, scale: 0.98 },
show: { show: {
opacity: 1, opacity: 1,
y: 0, y: 0,
@ -59,40 +50,29 @@ const itemVariants: Variants = {
}; };
export const AssetsPage = () => { export const AssetsPage = () => {
const location = useLocation();
const { success, error } = useToast(); const { success, error } = useToast();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<AssetGroup[]>([]); const [groups, setGroups] = useState<AssetGroup[]>([]);
// Layout Density State // Search & Filter
const [viewMode, setViewMode] = useState<'grid' | 'compact' | 'table'>('grid');
// Search, Sort & Filter State
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("ALL"); const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type'>('newest');
const [filters, setFilters] = useState<AssetQueryFilters>({});
// Slide-over Filter Drawer & Admin Manager Controls // Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups)
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [isAdminManagerOpen, setIsAdminManagerOpen] = useState(false);
// Multi-Selection State (Shift+Click Engine)
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
// Recommended assets logic
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER" const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase()) ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: []; : [];
// Only recommend assets that the partner actually has permission to access, and deduplicate
const recommendedAssets = (() => { const recommendedAssets = (() => {
if (partnerGroupStrings.length === 0) return []; if (partnerGroupStrings.length === 0) return [];
const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase())); const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()));
return activeGroups return activeGroups
.flatMap(g => g.assets) .flatMap(g => g.assets)
.filter((recAsset, index, self) => .filter((recAsset, index, self) =>
@ -101,23 +81,6 @@ export const AssetsPage = () => {
); );
})(); })();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl", "transition-all", "duration-500");
setTimeout(() => {
el.classList.remove("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl");
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Modal Control States // Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
@ -129,6 +92,7 @@ export const AssetsPage = () => {
const [activeAsset, setActiveAsset] = useState<Asset | null>(null); const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null); const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null); const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null);
const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null); const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null);
@ -136,17 +100,21 @@ export const AssetsPage = () => {
fetchData(); fetchData();
}, []); }, []);
useEffect(() => {
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
setSelectedCategory("ALL");
}
}, [assets, selectedCategory]);
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const [assetsData, metaData, groupsData] = await Promise.all([ const assetsData = await getAssets();
getAssets({ ...filters, search: searchQuery, sortBy }),
getTaxonomyMeta(),
getAssetGroups(),
]);
setAssets(assetsData); setAssets(assetsData);
setTaxonomyMeta(metaData); setSelectedAssetIds([]);
// Load groups for both ADMIN (for managing) and PARTNER (for recommendations)
const groupsData = await getAssetGroups();
setGroups(groupsData); setGroups(groupsData);
if (user?.role === "ADMIN") { if (user?.role === "ADMIN") {
@ -160,53 +128,6 @@ export const AssetsPage = () => {
} }
}; };
// Re-fetch when query or filters change
useEffect(() => {
const timer = setTimeout(() => {
fetchFilteredAssets();
}, 250);
return () => clearTimeout(timer);
}, [searchQuery, sortBy, filters]);
const fetchFilteredAssets = async () => {
try {
const assetsData = await getAssets({
...filters,
search: searchQuery,
sortBy,
});
setAssets(assetsData);
} catch (err) {
console.error("Failed to filter assets", err);
}
};
// Shift + Click Range Multi-Selection Handler
const handleToggleSelectAsset = (assetId: string, event?: React.MouseEvent) => {
const clickedIndex = filteredAssets.findIndex(a => a.id === assetId);
if (event?.shiftKey && lastSelectedIndex !== null && clickedIndex !== -1) {
const start = Math.min(lastSelectedIndex, clickedIndex);
const end = Math.max(lastSelectedIndex, clickedIndex);
const rangeIds = filteredAssets.slice(start, end + 1).map(a => a.id);
setSelectedAssetIds(prev => Array.from(new Set([...prev, ...rangeIds])));
} else {
setSelectedAssetIds(prev =>
prev.includes(assetId) ? prev.filter(id => id !== assetId) : [...prev, assetId]
);
setLastSelectedIndex(clickedIndex);
}
};
const handleSelectAll = () => {
if (selectedAssetIds.length === filteredAssets.length) {
setSelectedAssetIds([]);
} else {
setSelectedAssetIds(filteredAssets.map(a => a.id));
}
};
const openEditModal = (asset: Asset) => { const openEditModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsEditOpen(true); setIsEditOpen(true);
@ -218,6 +139,14 @@ export const AssetsPage = () => {
setIsShareOpen(true); setIsShareOpen(true);
}; };
const handleToggleSelectAsset = (assetId: string) => {
setSelectedAssetIds((prev) =>
prev.includes(assetId)
? prev.filter((id) => id !== assetId)
: [...prev, assetId]
);
};
const openViewerModal = (asset: Asset) => { const openViewerModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsViewerOpen(true); setIsViewerOpen(true);
@ -243,6 +172,7 @@ export const AssetsPage = () => {
setAssetToDelete(null); setAssetToDelete(null);
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to delete asset", err);
error("Failed to delete asset", err.response?.data?.error || "Something went wrong."); error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -253,6 +183,7 @@ export const AssetsPage = () => {
success("Download request submitted", "An administrator has been notified of your request."); success("Download request submitted", "An administrator has been notified of your request.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to request download access", err);
error("Failed to submit request", err.response?.data?.error || "Something went wrong."); error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -263,6 +194,7 @@ export const AssetsPage = () => {
success("Download request approved", "The partner can now download this asset."); success("Download request approved", "The partner can now download this asset.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to approve request", err);
error("Failed to approve request", err.response?.data?.error || "Something went wrong."); error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -273,6 +205,7 @@ export const AssetsPage = () => {
success("Download request rejected", "The access request was denied."); success("Download request rejected", "The access request was denied.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to reject request", err);
error("Failed to reject request", err.response?.data?.error || "Something went wrong."); error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -281,6 +214,7 @@ export const AssetsPage = () => {
success("Download started", `Downloading "${asset.title}"...`); success("Download started", `Downloading "${asset.title}"...`);
try { try {
await downloadAssetFile(asset.id); await downloadAssetFile(asset.id);
const downloadUrl = asset.url.startsWith("http") const downloadUrl = asset.url.startsWith("http")
? asset.url ? asset.url
: `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`; : `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
@ -293,183 +227,119 @@ export const AssetsPage = () => {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
setAssets(prev => setAssets((prev) =>
prev.map(item => item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item) prev.map((item) =>
item.id === asset.id
? { ...item, downloadsCount: item.downloadsCount + 1 }
: item,
),
); );
} catch (err: any) { } catch (err: any) {
console.error("Failed to process download", err);
error("Download failed", err.response?.data?.error || "Could not retrieve asset file."); error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
} }
}; };
// Client-side filtering fallback
const filteredAssets = assets.filter((asset) => { const filteredAssets = assets.filter((asset) => {
if (selectedCategory === "RECOMMENDED") { const query = searchQuery.toLowerCase().trim();
return recommendedAssets.some((r) => r.id === asset.id);
}
return true;
});
const activeFilterCount = const matchesSearch =
(filters.verticalIds?.length || 0) + !query ||
(filters.contentTypes?.length || 0) + asset.title.toLowerCase().includes(query) ||
(filters.subcategories?.length || 0) + (asset.description && asset.description.toLowerCase().includes(query)) ||
(filters.tags?.length || 0); (asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
asset.type.toLowerCase().includes(query) ||
asset.tags.some((tag) => tag.toLowerCase().includes(query));
const matchesCategory =
selectedCategory === "ALL" ||
(selectedCategory === "RECOMMENDED" &&
recommendedAssets.some((r) => r.id === asset.id));
return matchesSearch && matchesCategory;
});
const pendingRequestsCount = assets.reduce((acc, asset) => { const pendingRequestsCount = assets.reduce((acc, asset) => {
return ( return (
acc + (asset.downloadRequests?.filter((r) => r.status === "PENDING").length || 0) acc +
(asset.downloadRequests?.filter((r) => r.status === "PENDING").length ||
0)
); );
}, 0); }, 0);
// Header component // Header component
const headerNode = ( const headerNode = (
<PageHeader <PageHeader
title="Asset Discovery Platform" title="Asset Library"
subtitle="Enterprise asset catalog featuring vertical domain search, multi-view density, and partner access controls." subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
// badge={
// <div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <CheckCircle className="w-3.5 h-3.5 text-ink-900" />
// <span>Global CDN Active</span>
// </div>
// }
/> />
); );
// Toolbar component // Toolbar component
const toolbarNode = ( const toolbarNode = (
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between p-3.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm"> <div className="flex flex-col md:flex-row gap-4 items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex flex-wrap items-center gap-3 flex-1 min-w-0"> <div className="flex flex-col md:flex-row items-stretch md:items-center gap-3 flex-1 min-w-0 w-full">
{/* Search Bar with stable minimum width */}
{/* Search Bar */} <div className="relative w-full md:w-[280px] shrink-0 group">
<div className="relative w-full sm:w-[280px] shrink-0"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
</div>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-2 pl-9 pr-4 text-xs font-medium text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none focus:ring-2 ring-slate-400/20 focus:border-slate-500 transition-all" className="w-full bg-ink-50 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 hover:border-ink-300 font-sans"
placeholder="Search catalog titles, tags..." placeholder="Search by title, desc, tag, category..."
/> />
</div> </div>
{/* Sorting Dropdown */} {/* Toggle Controls: All Assets vs Recommended */}
<div className="flex items-center gap-1.5 bg-slate-50 dark:bg-slate-800 px-2.5 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<ArrowUpDown className="w-3.5 h-3.5 text-slate-400" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="bg-transparent text-xs font-semibold text-slate-700 dark:text-slate-300 outline-none cursor-pointer"
>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
<option value="title_asc">Title A-Z</option>
<option value="title_desc">Title Z-A</option>
<option value="type">Format / Type</option>
</select>
</div>
{/* View Density Switcher */}
<div className="flex items-center p-0.5 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'grid'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Grid Cards"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('compact')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'compact'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Compact Cards"
>
<LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'table'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Tabular List View (Shift+Click)"
>
<List className="w-4 h-4" />
</button>
</div>
{/* Partner Recommended Toggle */}
{user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && ( {user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && (
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl shrink-0"> <div className="flex items-center gap-1 bg-ink-50/50 p-1 rounded-xl border border-ink-200 shadow-sm shrink-0">
<button <button
onClick={() => setSelectedCategory("ALL")} onClick={() => setSelectedCategory("ALL")}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${selectedCategory === "ALL" className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap ${selectedCategory === "ALL"
? "bg-slate-900 text-white shadow-xs" ? "bg-ink-900 text-ink-0 shadow-sm"
: "text-slate-600 dark:text-slate-300 hover:bg-slate-200" : "text-ink-600 hover:text-ink-900 hover:bg-ink-100/50"
}`} }`}
> >
All Assets All Assets
</button> </button>
<button <button
onClick={() => setSelectedCategory("RECOMMENDED")} onClick={() => setSelectedCategory("RECOMMENDED")}
className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${selectedCategory === "RECOMMENDED" className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap flex items-center gap-1.5 ${selectedCategory === "RECOMMENDED"
? "bg-amber-500 text-slate-950 font-bold shadow-xs" ? "bg-gradient-to-r from-amber-500 to-amber-600 text-zinc-950 shadow-md shadow-amber-500/25"
: "text-amber-600 dark:text-amber-400 hover:bg-amber-500/10" : "text-amber-700 dark:text-amber-400 hover:bg-amber-500/10"
}`} }`}
> >
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5 animate-pulse" />
<span>Recommended ({recommendedAssets.length})</span> <span>Recommended for You</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* Right Action & Filter Drawer Trigger Area */} <div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
<div className="flex items-center gap-2 shrink-0 justify-end">
{user?.role === "ADMIN" && (
<>
{selectedAssetIds.length > 0 ? (
<Button
onClick={() => setSelectedAssetIds([])}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Deselect All
</Button>
) : (
<Button
onClick={handleSelectAll}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Select All
</Button>
)}
<Button
onClick={() => setIsAdminManagerOpen(true)}
variant="secondary"
size="sm"
icon={<Shield className="w-3.5 h-3.5 text-slate-700 dark:text-slate-300" />}
>
Taxonomy & Announcements
</Button>
</>
)}
{user?.role === "ADMIN" && pendingRequestsCount > 0 && ( {user?.role === "ADMIN" && pendingRequestsCount > 0 && (
<Button <Button
onClick={() => setIsRequestsOpen(true)} onClick={() => setIsRequestsOpen(true)}
variant="secondary" variant="secondary"
size="sm" size="sm"
icon={ icon={
<span className="w-4 h-4 rounded-full bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-[10px] flex items-center justify-center font-bold"> <span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold mr-0.5">
{pendingRequestsCount} {pendingRequestsCount}
</span> </span>
} }
> >
Requests Download Requests
</Button> </Button>
)} )}
@ -483,7 +353,7 @@ export const AssetsPage = () => {
size="sm" size="sm"
icon={<Share2 className="w-3.5 h-3.5" />} icon={<Share2 className="w-3.5 h-3.5" />}
> >
Share ({selectedAssetIds.length}) Share Selected ({selectedAssetIds.length})
</Button> </Button>
)} )}
@ -494,7 +364,7 @@ export const AssetsPage = () => {
size="sm" size="sm"
icon={<Folder className="w-3.5 h-3.5" />} icon={<Folder className="w-3.5 h-3.5" />}
> >
Groups Manage Groups
</Button> </Button>
)} )}
@ -505,108 +375,65 @@ export const AssetsPage = () => {
size="sm" size="sm"
icon={<UploadCloud className="w-3.5 h-3.5" />} icon={<UploadCloud className="w-3.5 h-3.5" />}
> >
Create Asset Create / Upload Asset
</Button> </Button>
)} )}
{/* Filter Drawer Trigger Button positioned on the far right end */}
<button
onClick={() => setIsFilterDrawerOpen(true)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${activeFilterCount > 0
? "bg-slate-900 text-white border-slate-900 shadow-sm dark:bg-slate-100 dark:text-slate-900"
: "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
<Filter className="w-4 h-4" />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="px-1.5 py-0.2 text-[10px] font-extrabold rounded-full bg-amber-500 text-slate-950">
{activeFilterCount}
</span>
)}
</button>
</div> </div>
</div> </div>
); );
return ( return (
<PageLayout header={headerNode} toolbar={toolbarNode}> <PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar"> <div className="p-5 flex-1 min-h-0 overflow-y-auto">
{/* Result Counter & Active Filter Badges */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-xs">
<div className="text-slate-500 font-medium flex items-center gap-2">
<span>Showing <strong className="text-slate-900 dark:text-slate-100">{filteredAssets.length}</strong> of {taxonomyMeta?.totalAssets || assets.length} assets</span>
{selectedAssetIds.length > 0 && (
<span className="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 font-bold border border-slate-300 dark:border-slate-700">
{selectedAssetIds.length} selected
</span>
)}
</div>
{activeFilterCount > 0 && (
<button
onClick={() => setFilters({})}
className="text-xs text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-bold flex items-center gap-1"
>
<X className="w-3.5 h-3.5" />
Reset Filters ({activeFilterCount})
</button>
)}
</div>
{loading ? ( {loading ? (
<div className="py-20 flex justify-center items-center"> <div className="py-20 flex justify-center items-center">
<div className="w-8 h-8 border-4 border-slate-400/30 border-t-slate-900 dark:border-t-slate-100 rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
</div> </div>
) : ( ) : (
<> <>
{filteredAssets.length === 0 ? ( {selectedCategory === "RECOMMENDED" && (
<div className="py-16 text-center bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl"> <div className="mb-8 p-8 bg-gradient-to-br from-amber-500/[0.07] via-slate-900/40 to-slate-950/20 border border-amber-500/20 rounded-2xl relative overflow-hidden shadow-lg backdrop-blur-md">
<File className="w-12 h-12 text-slate-300 mx-auto mb-4" /> {/* Background decorative elements */}
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">No assets match your query</h3> <div className="absolute top-0 right-0 w-80 h-80 bg-amber-500/[0.04] rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
<p className="text-slate-500 text-xs mt-1 max-w-sm mx-auto"> <div className="absolute -left-10 -bottom-10 w-60 h-60 bg-amber-600/[0.02] rounded-full blur-3xl pointer-events-none" />
Try broadening your search keywords or clearing active domain filters.
</p> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
<button <div className="space-y-2">
onClick={() => { setSearchQuery(''); setFilters({}); }} <div className="flex flex-wrap items-center gap-2">
className="mt-4 px-4 py-2 rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-xs font-bold" <span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-xs font-bold text-amber-700 dark:text-amber-400 uppercase tracking-wider">
> <Sparkles className="w-3.5 h-3.5 animate-pulse" />
Clear All Filters <span>Curated Catalog</span>
</button> </span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md bg-ink-900/60 text-ink-200 text-[10px] font-bold uppercase tracking-wider border border-ink-700/30">
{recommendedAssets.length} {recommendedAssets.length === 1 ? 'Asset' : 'Assets'}
</span>
</div>
<h2 className="text-3xl font-extrabold text-ink-900 font-sans tracking-tight">Recommended for You</h2>
<p className="text-ink-600 text-sm max-w-2xl leading-relaxed">
These resources have been hand-picked by our team to match your partner profile and accelerate your integration.
</p>
</div>
</div>
</div>
)}
{filteredAssets.length === 0 ? (
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
<p className="text-ink-500 text-sm mt-1">
There are no assets matching your criteria.
</p>
</div> </div>
) : viewMode === 'table' ? (
/* TABULAR LIST VIEW */
<AssetTableView
assets={filteredAssets}
selectedIds={selectedAssetIds}
recommendedIds={recommendedAssets.map(r => r.id)}
onToggleSelect={handleToggleSelectAsset}
onSelectAll={handleSelectAll}
onOpenAsset={openViewerModal}
onRequestDownload={(id) => {
const asset = assets.find(a => a.id === id);
if (asset) handleRequestDownload(asset);
}}
userRole={user?.role}
/>
) : ( ) : (
/* GRID / COMPACT VIEW */
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="show" animate="show"
className={`grid grid-cols-1 ${viewMode === 'compact' className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-start"
? 'sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3'
: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6'
} items-start`}
> >
{filteredAssets.map((asset) => ( {filteredAssets.map((asset) => (
<motion.div <motion.div key={asset.id} variants={itemVariants} className="relative h-[410px] w-full flex flex-col">
key={asset.id}
variants={itemVariants}
className={`relative ${viewMode === 'compact' ? 'h-[360px]' : 'h-[410px]'} w-full flex flex-col`}
>
<AssetCard <AssetCard
asset={asset} asset={asset}
user={user} user={user}
@ -620,7 +447,7 @@ export const AssetsPage = () => {
onDownload={handleDownload} onDownload={handleDownload}
onRequestDownload={handleRequestDownload} onRequestDownload={handleRequestDownload}
isSelected={selectedAssetIds.includes(asset.id)} isSelected={selectedAssetIds.includes(asset.id)}
onToggleSelect={(id, event) => handleToggleSelectAsset(id, event)} onToggleSelect={handleToggleSelectAsset}
isExpanded={expandedAssetId === asset.id} isExpanded={expandedAssetId === asset.id}
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)} onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
isRecommended={recommendedAssets.some(r => r.id === asset.id)} isRecommended={recommendedAssets.some(r => r.id === asset.id)}
@ -633,26 +460,6 @@ export const AssetsPage = () => {
)} )}
</div> </div>
{/* Slide-over Filter Drawer */}
<FilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
meta={taxonomyMeta}
filters={filters}
onChangeFilters={setFilters}
onClearAll={() => setFilters({})}
/>
{/* Admin Taxonomy & Announcements Control Modal */}
<AssetAdminManagerModal
isOpen={isAdminManagerOpen}
onClose={() => setIsAdminManagerOpen(false)}
meta={taxonomyMeta}
organizations={organizations}
allAssets={assets}
onRefreshMeta={fetchData}
/>
{/* Modals Container */} {/* Modals Container */}
<UploadAssetModal <UploadAssetModal
isOpen={isUploadOpen} isOpen={isUploadOpen}
@ -729,8 +536,8 @@ export const AssetsPage = () => {
size="sm" size="sm"
> >
<div className="space-y-4 font-sans"> <div className="space-y-4 font-sans">
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed"> <p className="text-xs text-ink-600 leading-relaxed">
Are you sure you want to permanently delete <span className="font-bold text-slate-900 dark:text-white">"{assetToDelete?.title}"</span>? This action cannot be undone. Are you sure you want to permanently delete <span className="font-bold text-ink-900">"{assetToDelete?.title}"</span>? This action cannot be undone.
</p> </p>
<div className="flex gap-3 justify-end pt-2"> <div className="flex gap-3 justify-end pt-2">
<Button <Button

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { useLocation } from 'react-router-dom'; import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw } from 'lucide-react';
import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from '../hooks/use-auth';
import { useMyAcceptancesQuery } from '../hooks/use-legal-query'; import { useMyAcceptancesQuery } from '../hooks/use-legal-query';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
@ -9,28 +8,10 @@ import { PageLayout } from '../components/layout/PageLayout';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
export const ClientAgreementsPage: React.FC = () => { export const ClientAgreementsPage: React.FC = () => {
const location = useLocation();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery(); const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery();
const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null); const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !isLoading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`) || document.getElementById('asset-card-nda');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, isLoading]);
const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA'); const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA'); const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA');
@ -80,49 +61,12 @@ export const ClientAgreementsPage: React.FC = () => {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* NDA Card */} {/* NDA Card */}
<div <div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300">
id={`asset-card-${ndaAcceptance?.document?.id || 'nda'}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: ndaAcceptance?.document?.id || 'nda',
title: 'Mutual Non-Disclosure Agreement (NDA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Required to protect proprietary IP, silicon designs, and private data sharing.',
url: ndaAcceptance?.documentUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', 'Mutual Non-Disclosure Agreement (NDA)');
}}
className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500 cursor-pointer"
>
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: ndaAcceptance?.document?.id || 'nda',
title: 'Mutual Non-Disclosure Agreement (NDA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Required to protect proprietary IP, silicon designs, and private data sharing.',
url: ndaAcceptance?.documentUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect NDA with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
</div>
{ndaAcceptance ? ( {ndaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250"> <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
@ -192,49 +136,12 @@ export const ClientAgreementsPage: React.FC = () => {
</div> </div>
{/* MSA Card */} {/* MSA Card */}
<div <div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300">
id={`asset-card-${msaAcceptance?.document?.id || 'msa'}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: msaAcceptance?.document?.id || 'msa',
title: 'Master Services Agreement (MSA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Defines commercial framework, SLA guidelines, and consulting provisions.',
url: msaAcceptance?.documentUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', 'Master Services Agreement (MSA)');
}}
className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500 cursor-pointer"
>
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: msaAcceptance?.document?.id || 'msa',
title: 'Master Services Agreement (MSA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Defines commercial framework, SLA guidelines, and consulting provisions.',
url: msaAcceptance?.documentUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect MSA with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
</div>
{msaAcceptance ? ( {msaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250"> <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Code, Code,
@ -8,8 +7,7 @@ import {
Cloud, Cloud,
Globe, Globe,
ExternalLink, ExternalLink,
Layers, Layers
Sparkles
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout'; import { PageLayout } from '../components/layout/PageLayout';
@ -26,28 +24,10 @@ const iconMap: Record<string, any> = {
}; };
export const EcosystemPage: React.FC = () => { export const EcosystemPage: React.FC = () => {
const location = useLocation();
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]); const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL'); const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
@ -90,7 +70,8 @@ export const EcosystemPage: React.FC = () => {
<button <button
key={type} key={type}
onClick={() => setFilter(type)} onClick={() => setFilter(type)}
className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 whitespace-nowrap cursor-pointer ${filter === type className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 whitespace-nowrap cursor-pointer ${
filter === type
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200/50' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200/50'
: 'text-ink-500 hover:text-ink-900' : 'text-ink-500 hover:text-ink-900'
}`} }`}
@ -117,7 +98,7 @@ export const EcosystemPage: React.FC = () => {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }} exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8" className="grid grid-cols-1 md:grid-cols-2 gap-8"
> >
{filteredOfferings.map((offering) => { {filteredOfferings.map((offering) => {
const IconComponent = iconMap[offering.logoIcon] || Globe; const IconComponent = iconMap[offering.logoIcon] || Globe;
@ -126,27 +107,12 @@ export const EcosystemPage: React.FC = () => {
return ( return (
<div <div
key={offering.id} key={offering.id}
id={`asset-card-${offering.id}`} className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500"
draggable={true}
onDragStart={(e) => {
const payload = {
id: offering.id,
title: offering.name,
type: offering.type,
entityKind: 'ECOSYSTEM',
url: offering.websiteUrl,
description: offering.description,
tagline: offering.tagline,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', offering.name);
}}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500 cursor-pointer"
> >
<div> <div>
{/* Logo and Badges */} {/* Logo and Badges */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div className="h-10 flex items-center shrink-0 gap-3"> <div className="h-10 flex items-center shrink-0">
{offering.logoUrl ? ( {offering.logoUrl ? (
<BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-900 dark:text-ink-0" /> <BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-900 dark:text-ink-0" />
) : ( ) : (
@ -156,29 +122,8 @@ export const EcosystemPage: React.FC = () => {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${
<button isProduct
onClick={(e) => {
e.stopPropagation();
const payload = {
id: offering.id,
title: offering.name,
type: offering.type,
entityKind: 'ECOSYSTEM',
url: offering.websiteUrl,
description: offering.description,
tagline: offering.tagline,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect Offering with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${isProduct
? 'bg-blue-500/10 text-blue-600 border-blue-500/20' ? 'bg-blue-500/10 text-blue-600 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' : 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
}`}> }`}>
@ -186,7 +131,6 @@ export const EcosystemPage: React.FC = () => {
{offering.type} {offering.type}
</span> </span>
</div> </div>
</div>
{/* Info Copy */} {/* Info Copy */}
<div className="space-y-3"> <div className="space-y-3">
@ -208,7 +152,8 @@ export const EcosystemPage: React.FC = () => {
<ul className="grid grid-cols-1 gap-2.5 pt-1"> <ul className="grid grid-cols-1 gap-2.5 pt-1">
{offering.benefits.map((benefit, bIdx) => ( {offering.benefits.map((benefit, bIdx) => (
<li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600"> <li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${isProduct ? 'bg-blue-500' : 'bg-emerald-500' <span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${
isProduct ? 'bg-blue-500' : 'bg-emerald-500'
}`} /> }`} />
<span>{benefit}</span> <span>{benefit}</span>
</li> </li>

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Play, Play,
@ -8,8 +7,7 @@ import {
Video, Video,
Maximize2, Maximize2,
Tv, Tv,
Monitor, Monitor
Sparkles
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout'; import { PageLayout } from '../components/layout/PageLayout';
@ -129,7 +127,8 @@ const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, o
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<p <p
className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${isExpanded ? '' : 'line-clamp-2' className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${
isExpanded ? '' : 'line-clamp-2'
}`} }`}
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }} style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
> >
@ -151,29 +150,11 @@ const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, o
}; };
export const ShowcasePage: React.FC = () => { export const ShowcasePage: React.FC = () => {
const location = useLocation();
const [items, setItems] = useState<ContentShowcase[]>([]); const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null); const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null); const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Resizable Lightbox state: compact | theater | cinema // Resizable Lightbox state: compact | theater | cinema
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact'); const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
@ -229,7 +210,7 @@ export const ShowcasePage: React.FC = () => {
<p className="text-xs text-ink-500 mt-1 max-w-sm">There are no featured videos available in the showcase right now. Check back later!</p> <p className="text-xs text-ink-500 mt-1 max-w-sm">There are no featured videos available in the showcase right now. Check back later!</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 animate-fadeIn"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 animate-fadeIn">
{items.map((item) => { {items.map((item) => {
const ytId = extractYouTubeVideoId(item.youtubeUrl); const ytId = extractYouTubeVideoId(item.youtubeUrl);
const isIg = item.youtubeUrl.includes('instagram.com'); const isIg = item.youtubeUrl.includes('instagram.com');
@ -238,51 +219,18 @@ export const ShowcasePage: React.FC = () => {
const isExpanded = expandedItemId === item.id; const isExpanded = expandedItemId === item.id;
return ( return (
<div key={item.id} id={`asset-card-${item.id}`} className="relative h-[410px] w-full flex flex-col"> <div key={item.id} className="relative h-[410px] w-full flex flex-col">
<motion.div <motion.div
layout layout
draggable={true}
onDragStart={(e: any) => {
const payload = {
id: item.id,
title: item.title,
type: 'case_study',
entityKind: 'SHOWCASE',
url: item.youtubeUrl,
description: item.description,
thumbnailUrl: item.thumbnailUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', item.title);
}}
transition={{ type: "spring", stiffness: 320, damping: 28 }} transition={{ type: "spring", stiffness: 320, damping: 28 }}
className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${isExpanded className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${
isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0' ? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0'
: 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl' : 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl'
}`} }`}
> >
{/* Video Player / Thumbnail */} {/* Video Player / Thumbnail */}
<div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0"> <div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: item.id,
title: item.title,
type: 'case_study',
entityKind: 'SHOWCASE',
url: item.youtubeUrl,
description: item.description,
thumbnailUrl: item.thumbnailUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="absolute top-2 right-2 z-20 px-2 py-1 rounded-md bg-ink-900/90 text-ink-0 hover:bg-ink-950 text-[10px] font-bold shadow-md transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect Reel with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
{thumbnail ? ( {thumbnail ? (
<img <img
src={thumbnail} src={thumbnail}
@ -291,7 +239,8 @@ export const ShowcasePage: React.FC = () => {
/> />
) : ( ) : (
/* Platform Fallback Gradients */ /* Platform Fallback Gradients */
<div className={`w-full h-full flex items-center justify-center ${isIg <div className={`w-full h-full flex items-center justify-center ${
isIg
? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600' ? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600'
: isTw : isTw
? 'bg-ink-950' ? 'bg-ink-950'
@ -387,7 +336,8 @@ export const ShowcasePage: React.FC = () => {
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 250 }} transition={{ type: 'spring', damping: 25, stiffness: 250 }}
className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${lightboxSize === 'cinema' className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${
lightboxSize === 'cinema'
? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]' ? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]'
: lightboxSize === 'theater' : lightboxSize === 'theater'
? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]' ? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]'
@ -418,7 +368,8 @@ export const ShowcasePage: React.FC = () => {
</div> </div>
{/* Info Container */} {/* Info Container */}
<div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${lightboxSize === 'cinema' <div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${
lightboxSize === 'cinema'
? 'w-full border-t h-[40%] md:h-[30%] shrink-0' ? 'w-full border-t h-[40%] md:h-[30%] shrink-0'
: 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0' : 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0'
}`}> }`}>
@ -428,7 +379,8 @@ export const ShowcasePage: React.FC = () => {
<div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5"> <div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5">
<button <button
onClick={() => setLightboxSize('compact')} onClick={() => setLightboxSize('compact')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'compact' className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
lightboxSize === 'compact'
? 'bg-ink-800 text-ink-0' ? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300' : 'text-ink-500 hover:text-ink-300'
}`} }`}
@ -439,7 +391,8 @@ export const ShowcasePage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setLightboxSize('theater')} onClick={() => setLightboxSize('theater')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'theater' className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
lightboxSize === 'theater'
? 'bg-ink-800 text-ink-0' ? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300' : 'text-ink-500 hover:text-ink-300'
}`} }`}
@ -450,7 +403,8 @@ export const ShowcasePage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setLightboxSize('cinema')} onClick={() => setLightboxSize('cinema')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'cinema' className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${
lightboxSize === 'cinema'
? 'bg-ink-800 text-ink-0' ? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300' : 'text-ink-500 hover:text-ink-300'
}`} }`}

View File

@ -1,19 +1,8 @@
import { axiosInstance } from './axios'; import { axiosInstance } from './axios';
import type { Asset, Organization, Vertical, TechStack, EngagementType, ComplianceStandard, TaxonomyMeta, AssetQueryFilters } from '../types/assets'; import type { Asset, Organization } from '../types/assets';
export const getAssets = async (filters?: AssetQueryFilters): Promise<Asset[]> => { export const getAssets = async (): Promise<Asset[]> => {
const params: Record<string, string> = {}; const response = await axiosInstance.get<Asset[]>('/assets');
if (filters?.search) params.search = filters.search;
if (filters?.verticalIds?.length) params.verticalIds = filters.verticalIds.join(',');
if (filters?.techStackIds?.length) params.techStackIds = filters.techStackIds.join(',');
if (filters?.engagementTypeIds?.length) params.engagementTypeIds = filters.engagementTypeIds.join(',');
if (filters?.complianceIds?.length) params.complianceIds = filters.complianceIds.join(',');
if (filters?.contentTypes?.length) params.contentTypes = filters.contentTypes.join(',');
if (filters?.subcategories?.length) params.subcategories = filters.subcategories.join(',');
if (filters?.tags?.length) params.tags = filters.tags.join(',');
if (filters?.sortBy) params.sortBy = filters.sortBy;
const response = await axiosInstance.get<Asset[]>('/assets', { params });
return response.data; return response.data;
}; };
@ -35,10 +24,6 @@ export const updateAsset = async (
description?: string; description?: string;
categoryId?: string; categoryId?: string;
subcategory?: string; subcategory?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
tags?: string[]; tags?: string[];
githubUrl?: string; githubUrl?: string;
isDownloadable?: boolean; isDownloadable?: boolean;
@ -119,61 +104,3 @@ export const scrapeCaseStudy = async (url: string): Promise<ScrapedCaseStudy> =>
}); });
return response.data; return response.data;
}; };
// Taxonomy API
export const getVerticals = async (): Promise<Vertical[]> => {
const response = await axiosInstance.get<Vertical[]>('/taxonomy/verticals');
return response.data;
};
export const getTaxonomyMeta = async (): Promise<TaxonomyMeta> => {
const response = await axiosInstance.get<TaxonomyMeta>('/taxonomy/meta');
return response.data;
};
export const createVertical = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<Vertical> => {
const response = await axiosInstance.post<Vertical>('/taxonomy/verticals', payload);
return response.data;
};
export const updateVertical = async (id: string, payload: Partial<Vertical>): Promise<Vertical> => {
const response = await axiosInstance.put<Vertical>(`/taxonomy/verticals/${id}`, payload);
return response.data;
};
export const deleteVertical = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/verticals/${id}`);
};
export const createTechStack = async (payload: { name: string; category?: string; icon?: string; description?: string; color?: string }): Promise<TechStack> => {
const response = await axiosInstance.post<TechStack>('/taxonomy/tech-stacks', payload);
return response.data;
};
export const deleteTechStack = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/tech-stacks/${id}`);
};
export const createEngagementType = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<EngagementType> => {
const response = await axiosInstance.post<EngagementType>('/taxonomy/engagement-types', payload);
return response.data;
};
export const deleteEngagementType = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/engagement-types/${id}`);
};
export const createComplianceStandard = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<ComplianceStandard> => {
const response = await axiosInstance.post<ComplianceStandard>('/taxonomy/compliance-standards', payload);
return response.data;
};
export const deleteComplianceStandard = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/compliance-standards/${id}`);
};
// Notification API
export const sendAssetAnnouncement = async (payload: { title: string; message: string; targetOrgIds?: string[]; assetIds?: string[] }): Promise<void> => {
await axiosInstance.post('/assets/notify', payload);
};

View File

@ -26,87 +26,6 @@ export interface DownloadRequest {
}; };
} }
export interface Vertical {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface TechStack {
id: string;
name: string;
slug: string;
category: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface EngagementType {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface ComplianceStandard {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface TaxonomyMeta {
verticals: Vertical[];
techStacks: TechStack[];
engagementTypes: EngagementType[];
complianceStandards: ComplianceStandard[];
categories: { name: string; count: number }[];
subcategories: { name: string; count: number }[];
contentTypes: { name: string; count: number }[];
tags: { name: string; count: number }[];
totalAssets: number;
}
export interface AssetQueryFilters {
search?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
export interface Asset { export interface Asset {
id: string; id: string;
title: string; title: string;
@ -118,7 +37,6 @@ export interface Asset {
description: string | null; description: string | null;
categoryId: string | null; categoryId: string | null;
subcategory: string | null; subcategory: string | null;
contentType?: string | null;
tags: string[]; tags: string[];
downloadsCount: number; downloadsCount: number;
githubUrl: string | null; githubUrl: string | null;
@ -128,10 +46,6 @@ export interface Asset {
problemStatement?: string | null; problemStatement?: string | null;
solution?: string | null; solution?: string | null;
createdAt: string; createdAt: string;
verticals?: Vertical[];
techStacks?: TechStack[];
engagementTypes?: EngagementType[];
complianceStandards?: ComplianceStandard[];
sharedWith?: SharedWithOrg[]; sharedWith?: SharedWithOrg[];
downloadRequests?: DownloadRequest[]; downloadRequests?: DownloadRequest[];
} }

View File

@ -7,7 +7,8 @@ export default defineConfig({
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
server: { server: {
allowedHosts: [ allowedHosts: [
"hurricane-reverence-robin.ngrok-free.dev" "toughly-coinstantaneous-dimple.ngrok-free.dev",
"spruce-fridge-destiny.ngrok-free.dev"
], ],
cors: true, cors: true,
proxy: { proxy: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Binary file not shown.

2116
player_response.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -34,4 +34,4 @@ fi
# 3. Start Vite Dev Server # 3. Start Vite Dev Server
info "Starting Vite frontend dev server..." info "Starting Vite frontend dev server..."
node --max-old-space-size=512 node_modules/vite/bin/vite.js --host npm run dev -- --host

83
youtube.html Normal file

File diff suppressed because one or more lines are too long

83
youtube_correct.html Normal file

File diff suppressed because one or more lines are too long

83
youtube_l.html Normal file

File diff suppressed because one or more lines are too long

1132
ytInitialData.json Normal file

File diff suppressed because it is too large Load Diff