Compare commits
14 Commits
e7f50a6667
...
5254e347a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5254e347a8 | ||
|
|
643bb1ce68 | ||
|
|
2d730c8389 | ||
|
|
cf21405c53 | ||
|
|
7c6eebb151 | ||
|
|
4da31c52c0 | ||
|
|
6d694efeb0 | ||
|
|
63b3835ecf | ||
|
|
6a22f954c7 | ||
|
|
e6d3d07fd4 | ||
|
|
c32c4b9499 | ||
|
|
5ec1faa1f9 | ||
|
|
33e6a658f4 | ||
|
|
ea7d688f7c |
9
.gitignore
vendored
9
.gitignore
vendored
@ -28,4 +28,11 @@ uploads/*
|
|||||||
.github/*
|
.github/*
|
||||||
.next/*
|
.next/*
|
||||||
.env.local
|
.env.local
|
||||||
.vite/*
|
.vite/*
|
||||||
|
|
||||||
|
# Developer Documentation
|
||||||
|
/rules.md
|
||||||
|
/memory.md
|
||||||
|
/phases.md
|
||||||
|
/architecture.md
|
||||||
|
/Guide.md
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import prisma from '../utils/db';
|
import prisma from '../utils/db';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { MailService } from './mail.service';
|
import { MailService } from './mail.service';
|
||||||
|
import { s3Client, BUCKET_NAME } from '../utils/s3';
|
||||||
|
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
export class AssetService {
|
export class AssetService {
|
||||||
private mailService = new MailService();
|
private mailService = new MailService();
|
||||||
@ -377,6 +379,19 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAsset(id: string) {
|
public async deleteAsset(id: string) {
|
||||||
|
const asset = await prisma.asset.findUnique({ where: { id } });
|
||||||
|
if (asset && asset.url.startsWith('/uploads/')) {
|
||||||
|
const filename = asset.url.replace('/uploads/', '');
|
||||||
|
try {
|
||||||
|
await s3Client.send(new DeleteObjectCommand({
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: filename,
|
||||||
|
}));
|
||||||
|
console.log(`[S3] Deleted file: ${filename}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[S3] Failed to delete file ${filename}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
return await prisma.asset.delete({ where: { id } });
|
return await prisma.asset.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
213
Channel-Frontend/package-lock.json
generated
213
Channel-Frontend/package-lock.json
generated
@ -13,12 +13,14 @@
|
|||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-router": "^1.170.17",
|
"@tanstack/react-router": "^1.170.17",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"docx-preview": "^0.4.0",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-hook-form": "^7.81.0",
|
"react-hook-form": "^7.81.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@ -1211,6 +1213,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@ -1359,6 +1370,28 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"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/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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@ -1390,6 +1423,24 @@
|
|||||||
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
||||||
"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/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/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@ -1433,6 +1484,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docx-preview": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-OdKtE/uj3M4RfGarLkGjahUzRg8/kBp0Sraj1r1NAY1tp/sTpHOBqDrzVf9onMBt9vxP6SdQ6bpLCUCsFwjgcA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": ">=3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@ -1577,6 +1637,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 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/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@ -1750,6 +1819,24 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"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/isbot": {
|
"node_modules/isbot": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.0.tgz",
|
||||||
@ -1769,6 +1856,27 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/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/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@ -2178,6 +2286,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -2234,6 +2348,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@ -2318,6 +2438,21 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||||
@ -2352,6 +2487,12 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@ -2385,6 +2526,12 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -2395,6 +2542,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/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/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||||
@ -2500,6 +2668,12 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.2",
|
"version": "8.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
|
||||||
@ -2578,6 +2752,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|||||||
@ -15,12 +15,14 @@
|
|||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-router": "^1.170.17",
|
"@tanstack/react-router": "^1.170.17",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"docx-preview": "^0.4.0",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-hook-form": "^7.81.0",
|
"react-hook-form": "^7.81.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Globe,
|
|
||||||
FileText,
|
FileText,
|
||||||
Image as ImageIcon,
|
|
||||||
File,
|
File,
|
||||||
Eye,
|
Eye,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@ -15,11 +13,23 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
BookOpen
|
Globe
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
import type { User } from '../../../types/auth';
|
import type { User } from '../../../types/auth';
|
||||||
import { axiosInstance } from '../../../services/axios';
|
import { axiosInstance } from '../../../services/axios';
|
||||||
|
import { PdfThumbnail } from './PdfThumbnail';
|
||||||
|
import { DocxThumbnail } from './DocxThumbnail';
|
||||||
|
import { SpreadsheetThumbnail } from './SpreadsheetThumbnail';
|
||||||
|
|
||||||
|
const getFriendlyHostname = (urlStr: string) => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(urlStr);
|
||||||
|
return urlObj.hostname.replace('www.', '');
|
||||||
|
} catch {
|
||||||
|
return 'website.com';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface AssetCardProps {
|
interface AssetCardProps {
|
||||||
asset: Asset;
|
asset: Asset;
|
||||||
@ -69,20 +79,34 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetIcon = (type: string) => {
|
|
||||||
if (type === 'case_study') return BookOpen;
|
|
||||||
if (type === 'url') return Globe;
|
|
||||||
if (type.includes('pdf')) return FileText;
|
|
||||||
if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon;
|
|
||||||
return File;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRenderable = (type: string, url: string) => {
|
const isRenderable = (type: string, url: string) => {
|
||||||
const isOffice = type.includes('word') || type.includes('presentation') || type.includes('sheet') ||
|
const isOffice = type.includes('word') || type.includes('presentation') || type.includes('sheet') ||
|
||||||
url.toLowerCase().endsWith('.docx') || url.toLowerCase().endsWith('.doc') ||
|
url.toLowerCase().endsWith('.docx') || url.toLowerCase().endsWith('.doc') ||
|
||||||
url.toLowerCase().endsWith('.pptx') || url.toLowerCase().endsWith('.ppt') ||
|
url.toLowerCase().endsWith('.pptx') || url.toLowerCase().endsWith('.ppt') ||
|
||||||
url.toLowerCase().endsWith('.xlsx') || url.toLowerCase().endsWith('.xls');
|
url.toLowerCase().endsWith('.xlsx') || url.toLowerCase().endsWith('.xls') ||
|
||||||
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg') || isOffice;
|
url.toLowerCase().endsWith('.csv') || type.includes('csv');
|
||||||
|
|
||||||
|
const isTextOrCode = url.toLowerCase().endsWith('.md') ||
|
||||||
|
url.toLowerCase().endsWith('.txt') ||
|
||||||
|
url.toLowerCase().endsWith('.json') ||
|
||||||
|
url.toLowerCase().endsWith('.js') ||
|
||||||
|
url.toLowerCase().endsWith('.ts') ||
|
||||||
|
url.toLowerCase().endsWith('.tsx') ||
|
||||||
|
url.toLowerCase().endsWith('.jsx') ||
|
||||||
|
url.toLowerCase().endsWith('.py') ||
|
||||||
|
url.toLowerCase().endsWith('.yaml') ||
|
||||||
|
url.toLowerCase().endsWith('.yml') ||
|
||||||
|
url.toLowerCase().endsWith('.css') ||
|
||||||
|
url.toLowerCase().endsWith('.html') ||
|
||||||
|
url.toLowerCase().endsWith('.sh') ||
|
||||||
|
type.includes('text') ||
|
||||||
|
type.includes('markdown') ||
|
||||||
|
type.includes('json') ||
|
||||||
|
type.includes('javascript');
|
||||||
|
|
||||||
|
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg') || isOffice || isTextOrCode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFullAssetUrl = (url: string) => {
|
const getFullAssetUrl = (url: string) => {
|
||||||
@ -104,7 +128,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
return resolvedUrl;
|
return resolvedUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Icon = getAssetIcon(asset.type);
|
|
||||||
|
|
||||||
const isImage = asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') || asset.url.match(/\.(png|jpe?g|gif|svg|webp)$/i);
|
const isImage = asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') || asset.url.match(/\.(png|jpe?g|gif|svg|webp)$/i);
|
||||||
const isPdf = asset.type.includes('pdf') || asset.url.toLowerCase().endsWith('.pdf');
|
const isPdf = asset.type.includes('pdf') || asset.url.toLowerCase().endsWith('.pdf');
|
||||||
@ -112,8 +136,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
const isPresentation = asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt');
|
const isPresentation = asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt');
|
||||||
const isSpreadsheet = asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv');
|
const isSpreadsheet = asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv');
|
||||||
|
|
||||||
const hasBanner = isImage || (asset.type === 'case_study' && !!asset.thumbnailUrl);
|
const hasBanner = isImage || !!asset.thumbnailUrl;
|
||||||
const bannerSrc = (asset.type === 'case_study' && asset.thumbnailUrl) ? asset.thumbnailUrl : asset.url;
|
const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -232,90 +256,304 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
if (placeholder) placeholder.style.display = 'flex';
|
if (placeholder) placeholder.style.display = 'flex';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : isPdf ? (
|
||||||
|
<PdfThumbnail
|
||||||
|
url={getFullAssetUrl(asset.url)}
|
||||||
|
title={asset.title}
|
||||||
|
fallback={
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative bg-red-500/[0.03] hover:bg-red-500/[0.06] transition-colors">
|
||||||
|
<div className="w-[105px] h-[135px] bg-ink-0 border border-red-500/20 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
|
||||||
|
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
|
||||||
|
PDF Document
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 mt-6 flex-grow pt-1">
|
||||||
|
<div className="h-1 bg-ink-200 rounded w-11/12" />
|
||||||
|
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||||
|
<div className="h-1 bg-ink-250 rounded w-10/12" />
|
||||||
|
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||||
|
<div className="h-1 bg-ink-200 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
|
||||||
|
<span>PDF RESOURCE</span>
|
||||||
|
<FileText className="w-3 h-3 text-red-650" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : isWord ? (
|
||||||
|
<DocxThumbnail
|
||||||
|
url={getFullAssetUrl(asset.url)}
|
||||||
|
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="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">
|
||||||
|
<span>PARTNER ASSET LIBRARY</span>
|
||||||
|
<span className="font-extrabold text-indigo-650">DOCX</span>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{asset.title}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded w-full" />
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded w-11/12" />
|
||||||
|
<div className="h-1.5 bg-slate-55 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[7px] text-slate-400 font-bold border-t border-slate-100 pt-1.5 mt-auto font-mono">
|
||||||
|
<span>PAGE 1 OF 1</span>
|
||||||
|
<span className="text-slate-350">CONFIDENTIAL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : isSpreadsheet ? (
|
||||||
|
<SpreadsheetThumbnail
|
||||||
|
url={getFullAssetUrl(asset.url)}
|
||||||
|
fallback={
|
||||||
|
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden flex flex-col justify-between">
|
||||||
|
<div className="h-5 bg-emerald-700 border-b border-emerald-800 flex items-center px-2 shrink-0 justify-between select-none">
|
||||||
|
<span className="text-[8px] font-extrabold text-white tracking-wider">Spreadsheet Editor</span>
|
||||||
|
<span className="text-[6px] font-extrabold text-emerald-100 bg-emerald-800/80 px-1 py-0.5 rounded uppercase">XLSX</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 bg-white font-mono text-[6px]">
|
||||||
|
<div className="h-4 bg-slate-100 border-b border-slate-200 flex shrink-0">
|
||||||
|
<div className="w-6 border-r border-slate-200 bg-slate-150 flex items-center justify-center text-slate-500 font-bold shrink-0"></div>
|
||||||
|
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">A</div>
|
||||||
|
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">B</div>
|
||||||
|
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">C</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold">D</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col divide-y divide-slate-100">
|
||||||
|
<div className="h-4 flex items-center bg-emerald-500/5 divide-x divide-slate-100">
|
||||||
|
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">1</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-emerald-900 truncate">
|
||||||
|
{asset.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 flex items-center bg-slate-55 divide-x divide-slate-100">
|
||||||
|
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">2</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Category</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Date</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Size</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-slate-50 border-t border-slate-200 flex items-center px-2 shrink-0 select-none justify-between">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="text-[6px] font-extrabold text-emerald-700 bg-white border-x border-t border-slate-200 px-1.5 py-0.5 rounded-t select-none leading-none">Sheet1</span>
|
||||||
|
<span className="text-[6px] text-slate-400 px-1 py-0.5 select-none leading-none">Sheet2</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[5px] text-slate-350 font-bold">READY</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : asset.type === 'url' ? (
|
||||||
|
<div className="w-full h-full flex flex-col bg-slate-900 border border-slate-800 relative overflow-hidden group/browser">
|
||||||
|
{/* Browser Header Bar */}
|
||||||
|
<div className="h-6 bg-slate-800/80 border-b border-slate-700/50 flex items-center px-3 gap-2 shrink-0 select-none">
|
||||||
|
{/* Windows Dots */}
|
||||||
|
<div className="flex gap-1.5 shrink-0">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
|
||||||
|
</div>
|
||||||
|
{/* URL Bar */}
|
||||||
|
<div className="flex-1 max-w-[150px] mx-auto bg-slate-900/60 border border-slate-700/30 rounded px-2 py-0.5 flex items-center gap-1 text-[9px] text-slate-400 font-mono select-none truncate">
|
||||||
|
<Globe className="w-2.5 h-2.5 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{getFriendlyHostname(asset.url)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Browser Body Web Mockup */}
|
||||||
|
<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 */}
|
||||||
|
<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]" />
|
||||||
|
|
||||||
|
{/* Mock Web Page Hero Abstract Layout */}
|
||||||
|
<div className="space-y-2 mt-1.5 relative z-10">
|
||||||
|
{/* 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="w-8 h-1.5 bg-indigo-400/30 rounded" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/* Mock Page Content */}
|
||||||
|
<div className="space-y-1 pt-1">
|
||||||
|
<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/10 rounded w-5/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Fallbacks / simulated thumbnails */}
|
{/* Mock Card Domain Display */}
|
||||||
<div
|
<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">
|
||||||
id={`fallback-${asset.id}`}
|
<div className="w-6 h-6 rounded bg-indigo-650 flex items-center justify-center text-[10px] font-black text-white shrink-0">
|
||||||
className="w-full h-full flex items-center justify-center"
|
{getFriendlyHostname(asset.url).charAt(0).toUpperCase()}
|
||||||
style={{ display: hasBanner ? 'none' : 'flex' }}
|
|
||||||
>
|
|
||||||
{isPdf ? (
|
|
||||||
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative">
|
|
||||||
<div className="w-14 h-18 bg-ink-0 border border-ink-200 shadow-sm rounded flex flex-col justify-between p-1.5 relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-0.5 text-[8px] font-extrabold uppercase text-center tracking-wider">
|
|
||||||
PDF
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 mt-4">
|
<div className="flex-1 min-w-0 text-left">
|
||||||
<div className="h-1 bg-ink-200 rounded w-5/6" />
|
<div className="text-[10px] font-bold text-white font-mono truncate leading-none">
|
||||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
{getFriendlyHostname(asset.url)}
|
||||||
<div className="h-1 bg-ink-200 rounded w-4/5" />
|
</div>
|
||||||
</div>
|
<div className="text-[7px] text-slate-500 font-mono mt-0.5 leading-none">
|
||||||
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-0.5 mt-1">
|
EXTERNAL PORTAL
|
||||||
<span>PDF</span>
|
</div>
|
||||||
<FileText className="w-2.5 h-2.5 text-red-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isPresentation ? (
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center w-full h-full p-4">
|
) : (
|
||||||
<div className="w-18 h-13 bg-amber-500 border border-amber-600 shadow-sm rounded-lg flex flex-col justify-between p-1.5 relative overflow-hidden">
|
<div
|
||||||
<div className="h-1.5 bg-ink-0/90 rounded w-3/4 mb-1" />
|
id={`fallback-${asset.id}`}
|
||||||
<div className="space-y-1">
|
className="w-full h-full flex items-center justify-center bg-ink-50 relative overflow-hidden"
|
||||||
<div className="h-1 bg-ink-0/60 rounded w-full" />
|
>
|
||||||
<div className="h-1 bg-ink-0/60 rounded w-5/6" />
|
{isPresentation ? (
|
||||||
|
<div className="w-full h-full flex bg-slate-900 border border-slate-800 text-left select-none relative overflow-hidden">
|
||||||
|
{/* Left slide thumbnails rail */}
|
||||||
|
<div className="w-10 bg-slate-950 border-r border-slate-850 flex flex-col items-center py-2 gap-1.5 shrink-0">
|
||||||
|
<div className="w-7 h-5 rounded bg-amber-600/30 border border-amber-500/50 flex items-center justify-center text-[7px] text-amber-500 font-bold">1</div>
|
||||||
|
<div className="w-7 h-5 rounded bg-slate-850 border border-slate-750 flex items-center justify-center text-[7px] text-slate-500">2</div>
|
||||||
|
<div className="w-7 h-5 rounded bg-slate-850 border border-slate-750 flex items-center justify-center text-[7px] text-slate-500">3</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[7px] text-ink-0/80 font-bold pt-0.5">
|
{/* Main slide content area */}
|
||||||
<span>SLIDE</span>
|
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-amber-950/60 p-3.5 flex flex-col justify-between relative">
|
||||||
<span className="font-extrabold">PPTX</span>
|
<div className="space-y-1.5 mt-2 relative z-10">
|
||||||
|
<span className="inline-flex px-1.5 py-0.5 rounded text-[7px] font-extrabold uppercase bg-amber-500 text-slate-950 tracking-wider">
|
||||||
|
Slide Show
|
||||||
|
</span>
|
||||||
|
<h4 className="text-[11px] font-extrabold text-white leading-tight font-sans line-clamp-2 mt-1">
|
||||||
|
{asset.title}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 pt-1.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-amber-500" />
|
||||||
|
<div className="h-1 bg-white/20 rounded w-5/6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-amber-500" />
|
||||||
|
<div className="h-1 bg-white/20 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-amber-500" />
|
||||||
|
<div className="h-1 bg-white/10 rounded w-4/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Slide Footer */}
|
||||||
|
<div className="flex items-center justify-between text-[7px] text-slate-405 font-bold border-t border-slate-800/80 pt-1.5 mt-auto relative z-10 font-mono">
|
||||||
|
<span>KEYNOTE PRESENTATION</span>
|
||||||
|
<span className="font-extrabold text-amber-550 text-[6px]">PPTX</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : 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="flex flex-col items-center justify-center w-full h-full p-4">
|
{/* Document Margins decorative elements */}
|
||||||
<div className="w-14 h-18 bg-ink-0 border border-ink-200 shadow-sm rounded flex flex-col justify-between p-1.5 relative overflow-hidden">
|
<div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" />
|
||||||
<div className="absolute top-0 right-0 left-0 bg-blue-600 text-ink-0 py-0.5 text-[8px] font-extrabold uppercase text-center tracking-wider">
|
|
||||||
DOC
|
{/* 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">
|
||||||
|
<span>PARTNER ASSET LIBRARY</span>
|
||||||
|
<span className="font-extrabold text-indigo-650">DOCX</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 mt-4">
|
|
||||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
{/* Page Title & Body Text Mockup */}
|
||||||
<div className="h-1 bg-ink-200 rounded w-5/6" />
|
<div className="space-y-2 flex-grow mt-3">
|
||||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
<h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2">
|
||||||
|
{asset.title}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded w-full" />
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded w-11/12" />
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded w-full" />
|
||||||
|
<div className="h-1.5 bg-slate-50 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-0.5 mt-1">
|
|
||||||
<span>WORD</span>
|
{/* Signature / Metadata stamp at the bottom */}
|
||||||
<FileText className="w-2.5 h-2.5 text-blue-500" />
|
<div className="flex items-center justify-between text-[7px] text-slate-400 font-bold border-t border-slate-100 pt-1.5 mt-auto font-mono">
|
||||||
|
<span>PAGE 1 OF 1</span>
|
||||||
|
<span className="text-slate-350">CONFIDENTIAL</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : isSpreadsheet ? (
|
||||||
) : isSpreadsheet ? (
|
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden flex flex-col justify-between">
|
||||||
<div className="flex flex-col items-center justify-center w-full h-full p-4">
|
{/* Excel Top Bar */}
|
||||||
<div className="w-16 h-13 bg-emerald-600 border border-emerald-700 shadow-sm rounded-lg flex flex-col justify-between p-1.5 relative overflow-hidden">
|
<div className="h-5 bg-emerald-700 border-b border-emerald-800 flex items-center px-2 shrink-0 justify-between select-none">
|
||||||
<div className="grid grid-cols-3 gap-0.5 mt-0.5">
|
<span className="text-[8px] font-extrabold text-white tracking-wider">Spreadsheet Editor</span>
|
||||||
<div className="h-1.5 bg-ink-0/90 rounded-sm" />
|
<span className="text-[6px] font-extrabold text-emerald-100 bg-emerald-800/80 px-1 py-0.5 rounded uppercase">XLSX</span>
|
||||||
<div className="h-1.5 bg-ink-0/70 rounded-sm" />
|
|
||||||
<div className="h-1.5 bg-ink-0/70 rounded-sm" />
|
|
||||||
<div className="h-1.5 bg-ink-0/60 rounded-sm" />
|
|
||||||
<div className="h-1.5 bg-ink-0/80 rounded-sm" />
|
|
||||||
<div className="h-1.5 bg-ink-0/60 rounded-sm" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[7px] text-emerald-100 font-bold pt-0.5">
|
|
||||||
<span>SHEET</span>
|
{/* Sheet Grid Layout */}
|
||||||
<span className="font-extrabold">XLSX</span>
|
<div className="flex-1 flex flex-col min-h-0 bg-white font-mono text-[6px]">
|
||||||
|
{/* Headers Row */}
|
||||||
|
<div className="h-4 bg-slate-100 border-b border-slate-200 flex shrink-0">
|
||||||
|
<div className="w-6 border-r border-slate-200 bg-slate-150 flex items-center justify-center text-slate-500 font-bold shrink-0"></div>
|
||||||
|
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">A</div>
|
||||||
|
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">B</div>
|
||||||
|
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">C</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold">D</div>
|
||||||
|
</div>
|
||||||
|
{/* Grid Rows */}
|
||||||
|
<div className="flex-1 flex flex-col divide-y divide-slate-100">
|
||||||
|
{/* Row 1 - Title */}
|
||||||
|
<div className="h-4 flex items-center bg-emerald-500/5 divide-x divide-slate-100">
|
||||||
|
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">1</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-emerald-900 truncate">
|
||||||
|
{asset.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Row 2 - Headers */}
|
||||||
|
<div className="h-4 flex items-center bg-slate-50 divide-x divide-slate-100">
|
||||||
|
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">2</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Category</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Date</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Size</div>
|
||||||
|
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Status</div>
|
||||||
|
</div>
|
||||||
|
{/* Row 3 - Data */}
|
||||||
|
<div className="h-4 flex items-center divide-x divide-slate-100">
|
||||||
|
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">3</div>
|
||||||
|
<div className="flex-1 px-1 text-slate-600 truncate">{asset.categoryId || 'General'}</div>
|
||||||
|
<div className="flex-1 px-1 text-slate-600 truncate">{new Date(asset.createdAt).toLocaleDateString()}</div>
|
||||||
|
<div className="flex-1 px-1 text-slate-600 truncate">{formatBytes(asset.size)}</div>
|
||||||
|
<div className="flex-1 px-1 text-emerald-650 font-bold truncate">Published</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excel Sheet Tab Footer */}
|
||||||
|
<div className="h-4 bg-slate-50 border-t border-slate-200 flex items-center px-2 shrink-0 select-none justify-between">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="text-[6px] font-extrabold text-emerald-700 bg-white border-x border-t border-slate-200 px-1.5 py-0.5 rounded-t select-none leading-none">Sheet1</span>
|
||||||
|
<span className="text-[6px] text-slate-400 px-1 py-0.5 select-none leading-none">Sheet2</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[5px] text-slate-350 font-bold">READY</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-ink-500/[0.03] hover:bg-ink-500/[0.06] transition-colors">
|
||||||
<div className="flex flex-col items-center justify-center w-full h-full p-4">
|
<div className="w-[105px] h-[135px] bg-ink-0 border border-ink-200 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
|
||||||
<div className="w-12 h-12 rounded-xl bg-ink-100 border border-ink-200 flex items-center justify-center shadow-inner relative group-hover:scale-105 transition-transform">
|
<div className="absolute top-0 right-0 left-0 bg-ink-700 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
|
||||||
<Icon className="w-6 h-6 text-ink-650" />
|
Resource File
|
||||||
<div className="absolute -bottom-1 -right-1 bg-ink-800 border border-ink-700 rounded px-1 py-0.5 text-[7px] font-bold text-ink-0 uppercase">
|
</div>
|
||||||
{asset.type === 'case_study' ? 'CASE STUDY' : asset.type === 'url' ? 'LINK' : 'FILE'}
|
<div className="flex-grow flex items-center justify-center pt-4">
|
||||||
|
<File className="w-8 h-8 text-ink-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
|
||||||
|
<span>BINARY</span>
|
||||||
|
<span className="font-extrabold uppercase text-[6px]">{asset.type.split('/').pop() || 'FILE'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</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">
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
|||||||
isOpen={isOpen && !!asset}
|
isOpen={isOpen && !!asset}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Asset Details"
|
title="Asset Details"
|
||||||
size="md"
|
size="2xl"
|
||||||
footer={
|
footer={
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@ -42,101 +42,107 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{asset && (
|
{asset && (
|
||||||
<div className="space-y-4 text-xs font-medium text-ink-900">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 text-xs font-medium text-ink-900">
|
||||||
<div>
|
{/* Left Column */}
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4>
|
<div className="space-y-4">
|
||||||
<p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{asset.description && (
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Description</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4>
|
||||||
<p className="text-ink-700 mt-1 leading-relaxed font-sans">{asset.description}</p>
|
<p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{asset.type === 'case_study' && asset.thumbnailUrl && (
|
{asset.description && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Description</h4>
|
||||||
<div className="w-full h-40 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
|
<p className="text-ink-700 mt-1 leading-relaxed font-sans">{asset.description}</p>
|
||||||
<img
|
</div>
|
||||||
src={asset.thumbnailUrl}
|
)}
|
||||||
alt="Case Study Banner"
|
|
||||||
className="w-full h-full object-cover"
|
{(asset.type === 'case_study' || asset.thumbnailUrl) && asset.thumbnailUrl && (
|
||||||
/>
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4>
|
||||||
|
<div className="w-full h-44 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
|
||||||
|
<img
|
||||||
|
src={asset.thumbnailUrl}
|
||||||
|
alt="Asset Banner"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
|
||||||
|
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.categoryId || 'General'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Subcategory</h4>
|
||||||
|
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.subcategory || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{asset.type === 'case_study' && asset.problemStatement && (
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-red-600 font-sans mb-1">Problem / Challenge</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Size</h4>
|
||||||
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-red-500/[0.02] border border-red-500/10 p-3 rounded-lg shadow-sm">
|
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
|
||||||
{asset.problemStatement}
|
</div>
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Type</h4>
|
||||||
)}
|
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
|
||||||
|
|
||||||
{asset.type === 'case_study' && asset.solution && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-emerald-650 font-sans mb-1">Suggested Solution</h4>
|
|
||||||
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-emerald-500/[0.02] border border-emerald-500/10 p-3 rounded-lg shadow-sm">
|
|
||||||
{asset.solution}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
|
|
||||||
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.categoryId || 'General'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Subcategory</h4>
|
|
||||||
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.subcategory || '-'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Size</h4>
|
|
||||||
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Type</h4>
|
|
||||||
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{asset.tags.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4>
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
|
||||||
{asset.tags.map(tag => (
|
|
||||||
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600 font-sans">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{userRole === 'ADMIN' && asset.sharedWith && (
|
{/* Right Column */}
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Shared With</h4>
|
{asset.type === 'case_study' && asset.problemStatement && (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
<div>
|
||||||
{asset.sharedWith.length === 0 ? (
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-red-650 font-sans mb-1.5">Problem / Challenge</h4>
|
||||||
<span className="text-ink-500 font-semibold italic font-sans">Not shared with any organization</span>
|
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-red-500/[0.02] border border-red-500/10 p-3 rounded-lg shadow-sm">
|
||||||
) : (
|
{asset.problemStatement}
|
||||||
asset.sharedWith.map(sw => (
|
</p>
|
||||||
<span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold font-sans">
|
</div>
|
||||||
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
|
)}
|
||||||
|
|
||||||
|
{asset.type === 'case_study' && asset.solution && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-emerald-650 font-sans mb-1.5">Suggested Solution</h4>
|
||||||
|
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-emerald-500/[0.02] border border-emerald-500/10 p-3 rounded-lg shadow-sm">
|
||||||
|
{asset.solution}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{asset.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{asset.tags.map(tag => (
|
||||||
|
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600 font-sans">
|
||||||
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{userRole === 'ADMIN' && asset.sharedWith && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Shared With</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{asset.sharedWith.length === 0 ? (
|
||||||
|
<span className="text-ink-500 font-semibold italic font-sans">Not shared with any organization</span>
|
||||||
|
) : (
|
||||||
|
asset.sharedWith.map(sw => (
|
||||||
|
<span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold font-sans">
|
||||||
|
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { User } from '../../../types/auth';
|
|||||||
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 MarkdownViewer from '../../../components/ui/MarkdownViewer';
|
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
interface AssetViewerModalProps {
|
interface AssetViewerModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -25,6 +26,11 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
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('');
|
||||||
|
const [docBlob, setDocBlob] = useState<ArrayBuffer | null>(null);
|
||||||
|
const [excelHtml, setExcelHtml] = useState<string>('');
|
||||||
|
const [isLoadingDoc, setIsLoadingDoc] = useState(false);
|
||||||
|
const [docLoadError, setDocLoadError] = useState(false);
|
||||||
|
const wordContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getFullAssetUrl = (url: string) => {
|
const getFullAssetUrl = (url: string) => {
|
||||||
let resolvedUrl = url;
|
let resolvedUrl = url;
|
||||||
@ -86,6 +92,9 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
|
|
||||||
const isTextOrCodeAsset = (url: string, type: string) => {
|
const isTextOrCodeAsset = (url: string, type: string) => {
|
||||||
const assetUrl = url.toLowerCase();
|
const assetUrl = url.toLowerCase();
|
||||||
|
if (assetUrl.endsWith('.csv') || type.includes('csv')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
assetUrl.endsWith('.md') ||
|
assetUrl.endsWith('.md') ||
|
||||||
assetUrl.endsWith('.txt') ||
|
assetUrl.endsWith('.txt') ||
|
||||||
@ -130,10 +139,17 @@ 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 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) {
|
||||||
setTextPreviewContent('');
|
setTextPreviewContent('');
|
||||||
setIsLoadingText(false);
|
setIsLoadingText(false);
|
||||||
|
setDocBlob(null);
|
||||||
|
setExcelHtml('');
|
||||||
|
setIsLoadingDoc(false);
|
||||||
|
setDocLoadError(false);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
asset.type !== 'url' &&
|
asset.type !== 'url' &&
|
||||||
@ -179,9 +195,49 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (isWord || isSpreadsheet) {
|
||||||
|
setIsLoadingDoc(true);
|
||||||
|
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'arraybuffer' })
|
||||||
|
.then(res => {
|
||||||
|
const data = res.data;
|
||||||
|
setDocBlob(data);
|
||||||
|
|
||||||
|
if (isSpreadsheet) {
|
||||||
|
try {
|
||||||
|
const workbook = XLSX.read(data, { type: 'array' });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
|
const html = XLSX.utils.sheet_to_html(worksheet, { header: '', footer: '' });
|
||||||
|
setExcelHtml(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SheetJS Parsing error", err);
|
||||||
|
setDocLoadError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoadingDoc(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
setDocLoadError(true);
|
||||||
|
setIsLoadingDoc(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, asset]);
|
}, [isOpen, asset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWord && docBlob && wordContainerRef.current) {
|
||||||
|
wordContainerRef.current.innerHTML = '';
|
||||||
|
import('docx-preview').then(({ renderAsync }) => {
|
||||||
|
renderAsync(docBlob, wordContainerRef.current!)
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Docx Preview error:", err);
|
||||||
|
wordContainerRef.current!.innerHTML = '<div class="p-4 text-red-550 font-sans">Failed to render Word document preview. Please download the file to inspect.</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [docBlob, isWord]);
|
||||||
|
|
||||||
const formatBytes = (bytes: number, decimals = 2) => {
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@ -229,6 +285,30 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
Close Preview
|
Close Preview
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{asset && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const url = getFullAssetUrl(asset.url);
|
||||||
|
const isOffice = asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') ||
|
||||||
|
asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') ||
|
||||||
|
asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') ||
|
||||||
|
asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls');
|
||||||
|
|
||||||
|
if (isOffice && !isLocalUrl(asset.url)) {
|
||||||
|
window.open(`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`, '_blank');
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span>Open in New Tab</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{asset && asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
{asset && asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onDownload(asset)}
|
onClick={() => onDownload(asset)}
|
||||||
@ -275,46 +355,134 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
<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">
|
<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">
|
<span className="flex items-center gap-1.5">
|
||||||
<Globe className="w-4 h-4 text-ink-950" />
|
<Globe className="w-4 h-4 text-ink-950" />
|
||||||
<span className="font-sans">External Web Link Preview</span>
|
<span className="font-sans">External Web Link Portal Guide</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.url}</span>
|
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.url}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-6 md:p-12 flex items-center justify-center bg-ink-50">
|
|
||||||
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-6 shadow-xl max-w-xl w-full flex flex-col items-center text-center space-y-5">
|
<div className="flex-1 overflow-auto p-4 md:p-8 flex flex-col md:flex-row items-center justify-center gap-8 bg-ink-50">
|
||||||
{asset.thumbnailUrl ? (
|
{/* Visual Section: Browser Mockup */}
|
||||||
<div className="w-full h-48 border border-ink-100 rounded-xl overflow-hidden shadow-sm shrink-0">
|
<div className="w-full max-w-md aspect-video md:aspect-[4/3] rounded-2xl overflow-hidden shadow-lg border border-ink-200 bg-slate-900 flex flex-col shrink-0">
|
||||||
<img
|
{/* Browser Header Bar */}
|
||||||
src={asset.thumbnailUrl}
|
<div className="h-7 bg-slate-800 border-b border-slate-700/60 flex items-center px-4 gap-2 shrink-0 select-none">
|
||||||
alt="Web Preview Banner"
|
<div className="flex gap-1.5 shrink-0">
|
||||||
className="w-full h-full object-cover"
|
<span className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||||
/>
|
<span className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-green-500/80" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex-1 max-w-[200px] mx-auto bg-slate-900/60 border border-slate-700/30 rounded-md px-3 py-0.5 flex items-center gap-1.5 text-[10px] text-slate-400 font-mono select-none truncate">
|
||||||
<div className="w-16 h-16 rounded-2xl bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm shrink-0">
|
<Globe className="w-3 h-3 text-slate-500 shrink-0" />
|
||||||
<Globe className="w-8 h-8 text-ink-500" />
|
<span className="truncate">{(() => {
|
||||||
|
try {
|
||||||
|
return new URL(asset.url).hostname.replace('www.', '');
|
||||||
|
} catch {
|
||||||
|
return 'website.com';
|
||||||
|
}
|
||||||
|
})()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Browser Content */}
|
||||||
|
<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="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="w-16 h-2 bg-indigo-400/40 rounded" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<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/15 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 w-full">
|
<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">
|
||||||
<h3 className="text-base font-extrabold text-ink-950 font-sans leading-snug">{asset.title}</h3>
|
<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">
|
||||||
{asset.description ? (
|
{(() => {
|
||||||
<p className="text-xs text-ink-500 font-sans leading-relaxed px-4">{asset.description}</p>
|
try {
|
||||||
) : (
|
return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase();
|
||||||
<p className="text-xs text-ink-400 font-sans italic leading-relaxed">No description provided for this external link.</p>
|
} catch {
|
||||||
)}
|
return 'W';
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<div className="text-xs font-bold text-white font-mono truncate leading-none">
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return new URL(asset.url).hostname.replace('www.', '');
|
||||||
|
} catch {
|
||||||
|
return 'website.com';
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] text-slate-500 font-mono mt-1 leading-none tracking-wider">
|
||||||
|
SECURE PORTAL LINK
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info & Guide Section */}
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
</span>
|
||||||
|
<h3 className="text-lg font-extrabold text-ink-950 font-sans leading-snug mt-1.5">{asset.title}</h3>
|
||||||
|
{asset.description ? (
|
||||||
|
<p className="text-xs text-ink-500 font-sans mt-2 leading-relaxed">{asset.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-400 font-sans mt-2 italic">No description provided for this external portal link.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step-by-Step Access Guide */}
|
||||||
|
<div className="bg-ink-50 border border-ink-200 rounded-xl p-4 space-y-3">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider text-ink-400 block">Access Guide</span>
|
||||||
|
<ul className="space-y-2 text-xs text-ink-700 leading-relaxed font-sans">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-4 h-4 rounded-full bg-ink-200 text-ink-800 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">1</span>
|
||||||
|
<span>Click the primary button below or select <strong>Open in New Tab</strong> from the footer to access this asset.</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-4 h-4 rounded-full bg-ink-200 text-ink-800 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">2</span>
|
||||||
|
<span>If the partner resource requires authorization, use your partner account credentials to sign in.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full pt-2 px-4">
|
<div className="pt-6 border-t border-ink-100 w-full mt-4">
|
||||||
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
||||||
<a
|
<div className="space-y-2 w-full">
|
||||||
href={asset.url}
|
<a
|
||||||
target="_blank"
|
href={asset.url}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-850 transition-all hover:scale-[1.02] active:scale-95 shadow-md w-full font-sans cursor-pointer"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-850 transition-all hover:scale-[1.02] active:scale-95 shadow-md w-full font-sans cursor-pointer"
|
||||||
<span>Open Website in New Tab</span>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<span>Open Website in New Tab</span>
|
||||||
</a>
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(asset.url);
|
||||||
|
alert('Link copied to clipboard!');
|
||||||
|
}}
|
||||||
|
className="w-full text-center text-xs font-bold text-ink-600 hover:text-ink-900 bg-ink-50 hover:bg-ink-100 border border-ink-200 py-2 rounded-xl transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Copy Link Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-amber-50 border border-amber-200 text-amber-900 rounded-xl p-4 text-xs font-medium text-center font-sans">
|
<div className="bg-amber-50 border border-amber-200 text-amber-900 rounded-xl p-4 text-xs font-medium text-center font-sans">
|
||||||
Access Restricted: You must request and receive approval from the Administrator to open this external link.
|
Access Restricted: You must request and receive approval from the Administrator to open this external link.
|
||||||
@ -395,33 +563,99 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
|
) : (isWord || isSpreadsheet || asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt')) ? (
|
||||||
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
<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 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 justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
|
||||||
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="font-sans">Office Document Preview</span>
|
<FileText className="w-4 h-4 text-ink-950" />
|
||||||
|
<span className="font-sans">
|
||||||
|
{isWord ? 'Interactive Word Document Viewer' : isSpreadsheet ? 'Interactive Spreadsheet Grid Viewer' : 'Interactive Presentation Viewer'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{isLocalUrl(asset.url) ? (
|
|
||||||
|
{isLoadingDoc ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center space-y-3 bg-ink-50">
|
||||||
|
<div className="w-8 h-8 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
||||||
|
<span className="text-xs text-ink-500 font-medium font-sans">Loading and rendering document...</span>
|
||||||
|
</div>
|
||||||
|
) : docLoadError ? (
|
||||||
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
|
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
|
||||||
<div className="w-16 h-16 rounded-2xl bg-ink-55 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm">
|
<div className="w-16 h-16 rounded-2xl bg-red-50 border border-red-100 flex items-center justify-center text-red-650 shadow-sm shrink-0">
|
||||||
<FileText className="w-8 h-8" />
|
<FileText className="w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-bold text-ink-900 font-sans">Office Document Preview</h4>
|
<h4 className="text-sm font-bold text-red-950 font-sans">Failed to Render Document</h4>
|
||||||
<p className="text-xs text-ink-500 mt-2 leading-relaxed font-sans">
|
<p className="text-xs text-slate-500 mt-2 leading-relaxed font-sans">
|
||||||
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
|
We encountered an error loading or rendering this document ({asset.type.split('/').pop()?.toUpperCase() || 'FILE'}).
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left font-sans">
|
<p className="text-xs text-slate-500 mt-2 leading-relaxed bg-slate-50 border border-slate-100 rounded-xl p-3 text-left font-sans">
|
||||||
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost or ngrok tunnel), external Microsoft services cannot fetch your local files. Please download this asset using the button below to view it locally.
|
Please download the asset directly to view it on your device.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : isSpreadsheet ? (
|
||||||
|
<div className="flex-1 overflow-auto bg-slate-100 p-4 flex flex-col">
|
||||||
|
<div className="excel-preview-container flex-1 bg-white border border-slate-200 shadow-sm rounded-xl overflow-auto p-4">
|
||||||
|
<style>{`
|
||||||
|
.excel-preview-container table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.excel-preview-container th, .excel-preview-container td {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.excel-preview-container tr:nth-child(even) {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
.excel-preview-container tr:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: excelHtml || '<div class="p-4 text-slate-400 font-sans italic">No data found in this spreadsheet.</div>' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isWord ? (
|
||||||
|
<div className="flex-1 overflow-auto bg-slate-100 p-6 md:p-8 flex justify-center">
|
||||||
|
<div
|
||||||
|
ref={wordContainerRef}
|
||||||
|
className="w-full max-w-4xl bg-white shadow-md border border-slate-200 rounded-xl p-8 md:p-12 overflow-y-auto"
|
||||||
|
style={{ minHeight: '842px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
/* PowerPoint / Presentation */
|
||||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
|
isLocalUrl(asset.url) ? (
|
||||||
className="w-full flex-1 border-0 min-h-0"
|
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
|
||||||
title={asset.title}
|
<div className="w-16 h-16 rounded-2xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-950 shadow-sm shrink-0">
|
||||||
/>
|
<FileText className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-amber-950 font-sans">Presentation Slide Viewer</h4>
|
||||||
|
<p className="text-xs text-amber-900/80 mt-2 leading-relaxed font-sans">
|
||||||
|
This asset is a PowerPoint Presentation ({asset.type.split('/').pop()?.toUpperCase() || 'PPTX'}).
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-2 leading-relaxed bg-slate-50 border border-slate-100 rounded-xl p-3 text-left font-sans">
|
||||||
|
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost or ngrok tunnel), external Microsoft services cannot fetch your local files. Please download this asset using the button below to view it locally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
|
||||||
|
className="w-full flex-1 border-0 min-h-0"
|
||||||
|
title={asset.title}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { axiosInstance } from '../../../services/axios';
|
||||||
|
|
||||||
|
interface DocxThumbnailProps {
|
||||||
|
url: string;
|
||||||
|
fallback: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocxThumbnail: React.FC<DocxThumbnailProps> = ({ url, fallback }) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scale, setScale] = useState(0.2);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
axiosInstance.get(url, { responseType: 'arraybuffer' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
try {
|
||||||
|
const { renderAsync } = await import('docx-preview');
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = '';
|
||||||
|
await renderAsync(res.data, containerRef.current);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DocxThumbnail] Render error:', err);
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[DocxThumbnail] Fetch error:', err);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parentRef.current) return;
|
||||||
|
const updateScale = () => {
|
||||||
|
if (parentRef.current) {
|
||||||
|
const width = parentRef.current.offsetWidth || 280;
|
||||||
|
setScale(width / 800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateScale();
|
||||||
|
const observer = new ResizeObserver(updateScale);
|
||||||
|
observer.observe(parentRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <>{fallback}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none">
|
||||||
|
{loading && (
|
||||||
|
<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-indigo-600 border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute top-0 left-0 origin-top-left"
|
||||||
|
style={{
|
||||||
|
width: '800px',
|
||||||
|
height: '1000px',
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
padding: '16px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
129
Channel-Frontend/src/features/assets/components/PdfThumbnail.tsx
Normal file
129
Channel-Frontend/src/features/assets/components/PdfThumbnail.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface PdfThumbnailProps {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
fallback: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple in-memory cache for rendered thumbnails to avoid reprocessing the same PDFs during a session
|
||||||
|
const thumbnailCache: Record<string, string> = {};
|
||||||
|
|
||||||
|
export const PdfThumbnail: React.FC<PdfThumbnailProps> = ({ url, title, fallback }) => {
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(thumbnailCache[url] || null);
|
||||||
|
const [error, setError] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(!thumbnailCache[url]);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadPdfAndRender = async () => {
|
||||||
|
try {
|
||||||
|
// 1. Ensure PDF.js script is loaded dynamically
|
||||||
|
if (!(window as any).pdfjsLib) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error('Failed to load PDF.js library'));
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfjs = (window as any).pdfjsLib;
|
||||||
|
if (!pdfjs) throw new Error('PDF.js lib not available');
|
||||||
|
|
||||||
|
// Configure PDF.js Worker
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
|
||||||
|
|
||||||
|
// 2. Fetch and load the PDF document
|
||||||
|
const loadingTask = pdfjs.getDocument(url);
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 3. Load the first page of the PDF
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// Create a temporary canvas if not in DOM yet
|
||||||
|
const canvas = canvasRef.current || document.createElement('canvas');
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) throw new Error('Could not get 2D context');
|
||||||
|
|
||||||
|
// We want a thumbnail width of around 220px to fit the card well
|
||||||
|
const originalViewport = page.getViewport({ scale: 1.0 });
|
||||||
|
const scale = 220 / originalViewport.width;
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
|
// Render PDF page to canvas
|
||||||
|
const renderContext = {
|
||||||
|
canvasContext: context,
|
||||||
|
viewport: viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.render(renderContext).promise;
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// Convert canvas rendering to a base64 image URL for high performance image display
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
thumbnailCache[url] = dataUrl;
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setThumbnailUrl(dataUrl);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PdfThumbnail] Failed to render first page preview:', err);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPdfAndRender();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center relative overflow-hidden bg-ink-50">
|
||||||
|
{/* Hidden canvas for offscreen rendering */}
|
||||||
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full p-4 space-y-2">
|
||||||
|
<div className="w-5 h-5 rounded-full border-2 border-ink-400 border-t-transparent animate-spin" />
|
||||||
|
<span className="text-[10px] text-ink-400 font-medium">Generating preview...</span>
|
||||||
|
</div>
|
||||||
|
) : thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-cover object-top transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
fallback
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { axiosInstance } from '../../../services/axios';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
interface SpreadsheetThumbnailProps {
|
||||||
|
url: string;
|
||||||
|
fallback: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpreadsheetThumbnail: React.FC<SpreadsheetThumbnailProps> = ({ url, fallback }) => {
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [html, setHtml] = useState<string>('');
|
||||||
|
const [scale, setScale] = useState(0.2);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
axiosInstance.get(url, { responseType: 'arraybuffer' })
|
||||||
|
.then(res => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
try {
|
||||||
|
const workbook = XLSX.read(res.data, { type: 'array' });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
|
const sheetHtml = XLSX.utils.sheet_to_html(worksheet, { header: '', footer: '' });
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setHtml(sheetHtml);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SpreadsheetThumbnail] Render error:', err);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[SpreadsheetThumbnail] Fetch error:', err);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parentRef.current) return;
|
||||||
|
const updateScale = () => {
|
||||||
|
if (parentRef.current) {
|
||||||
|
const width = parentRef.current.offsetWidth || 280;
|
||||||
|
setScale(width / 900);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateScale();
|
||||||
|
const observer = new ResizeObserver(updateScale);
|
||||||
|
observer.observe(parentRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <>{fallback}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none p-1">
|
||||||
|
{loading && (
|
||||||
|
<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-emerald-600 border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="excel-thumbnail-container absolute top-0 left-0 origin-top-left"
|
||||||
|
style={{
|
||||||
|
width: '900px',
|
||||||
|
height: '600px',
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.excel-thumbnail-container table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.excel-thumbnail-container td {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
padding: 4px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.excel-thumbnail-container tr:nth-child(even) {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
Guide.md
186
Guide.md
@ -1,186 +0,0 @@
|
|||||||
# Channel Partner Portal & Secure Asset Management
|
|
||||||
|
|
||||||
## Enterprise Architecture & Implementation Guide
|
|
||||||
|
|
||||||
This guide details the complete system architecture, visual design specs, existing code evidence, and future implementation roadmap for the secure, multi-tenant Channel Partner Onboarding Platform.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Table of Contents
|
|
||||||
|
|
||||||
1. [Project Overview & Vision](#1-project-overview--vision)
|
|
||||||
2. [Database Schema (Prisma Models)](#2-database-schema-prisma-models)
|
|
||||||
3. [Completed Implementations & Code Evidence](#3-completed-implementations--code-evidence)
|
|
||||||
4. [MinIO S3-Compatible Storage Migration Guide](#4-minio-s3-compatible-storage-migration-guide)
|
|
||||||
5. [Future Development Roadmap & Milestones](#5-future-development-roadmap--milestones)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Project Overview & Vision
|
|
||||||
|
|
||||||
The Channel Partner Portal is a premium SaaS onboarding and secure document hub designed to look like modern workspaces (Notion/Linear) rather than standard tabular dashboards.
|
|
||||||
|
|
||||||
### Core Product Pillars
|
|
||||||
|
|
||||||
* **Secure Document Hub**: Multi-format document rendering engine with sandbox protections.
|
|
||||||
* **Role-Based Access Control (RBAC)**: Fine-grained permissions defining view-only vs. downloadable scopes.
|
|
||||||
* **Dynamic Sharing Scopes**: Direct share targeting to specific organizations or filtered to singular organization users.
|
|
||||||
* **Onboarding legal flow**: Gated portal requiring formal signature and tracking for NDA/MSA documents.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Database Schema (Prisma Models)
|
|
||||||
|
|
||||||
Our active PostgreSQL schema in `/Channel-Backend/prisma/schema.prisma` is designed around multi-tenancy and audit compliance:
|
|
||||||
|
|
||||||
| Model | Purpose | Key Relations |
|
|
||||||
| :------------------ | :--------------------------------------- | :------------------------------------------------------------------- |
|
|
||||||
| `Organization` | Tenant container | Has many`User` & `SharedAsset` |
|
|
||||||
| `User` | Authenticated users (Admin/Partner) | Belongs to`Organization`, Has many `DownloadRequest` |
|
|
||||||
| `Asset` | Physical/URL resource registry | Belongs to`Folder`, Has many `SharedAsset` & `DownloadRequest` |
|
|
||||||
| `SharedAsset` | Connects assets to organizations/users | Unique on`[assetId, organizationId, userId]` |
|
|
||||||
| `DownloadRequest` | Pending/Approved requests for download | Unique on`[assetId, userId]` |
|
|
||||||
| `LegalDocument` | Repository of NDA/MSA text templates | Versioned, Has many`LegalAcceptance` |
|
|
||||||
| `LegalAcceptance` | Tracks IP/hashes of accepted legal forms | Connected to`User` and `LegalDocument` |
|
|
||||||
| `AuditLog` | Immutable event tracker | Linked to actor (`User`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Completed Implementations & Code Evidence
|
|
||||||
|
|
||||||
Here is the exact code evidence showing which files and configuration settings run the current production-ready portal features.
|
|
||||||
|
|
||||||
### A. Backend security & CSP Configuration
|
|
||||||
|
|
||||||
We modified **[Channel-Backend/src/app.ts](file:///home/tech4biz/work/channel-partner/Tech4biz-channel/Channel-Backend/src/app.ts#L18-L30)** to support secure document embedding inside frontend iframes without exposing vulnerabilities:
|
|
||||||
|
|
||||||
* **Disabled Frameguard**: Removed default `X-Frame-Options: SAMEORIGIN` block.
|
|
||||||
* **Custom Content Security Policy**: Added `Content-Security-Policy` with the `frame-ancestors` directive allowlisting `http://localhost:5173` and `http://localhost:5000` to satisfy modern Chrome iframe sandbox checks.
|
|
||||||
* **TypeScript Safety**: Avoided default fallback blockers using `dangerouslyDisableDefaultSrc` and `useDefaults: false`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
app.use(helmet({
|
|
||||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
useDefaults: false,
|
|
||||||
directives: {
|
|
||||||
"default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
|
|
||||||
"frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
frameguard: false,
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
### B. Expandable Preview Modal & Multi-Engine Document Reader
|
|
||||||
|
|
||||||
We overhauled the modal system in **[Channel-Frontend/src/pages/AssetsPage.tsx](file:///home/tech4biz/work/channel-partner/Tech4biz-channel/Channel-Frontend/src/pages/AssetsPage.tsx#L1250-L1356)** to support multi-format previews:
|
|
||||||
|
|
||||||
* **Expandable Viewport Layout**: Added a Maximize/Minimize toggle button in the modal header. Maximizing updates the classes to `w-[96vw] h-[92vh] max-w-none` to prevent layout clipping and display long documents natively.
|
|
||||||
* **GitHub Repository Markdown Fetcher**: Added a regex parser (`getGithubRawUrl`) and hook to automatically extract the repository name, fetch the `README.md` raw file in the background, and output the text directly inside a clean, scrollable Inter-font document reader.
|
|
||||||
* **Office Online Document Preview**: Embedded a Microsoft Office Web Viewer wrapper for PowerPoint, Excel, and Word files, with a clean local-mode notice for developers working on `localhost` endpoints.
|
|
||||||
* **Secure Image/PDF Viewer**: Uses the standard PDF frame and constrained image container (`max-h-full object-contain`).
|
|
||||||
|
|
||||||
### C. Access Control and Protected Links
|
|
||||||
|
|
||||||
* **Removed Direct Links**: Removed the "Open Native Viewer" button to prevent users from bypassing access controls and downloading documents directly.
|
|
||||||
* **Protected Website URLs**: Blocked the "Open Link in New Tab" action for URL-based assets. It is now hidden behind the `isDownloadable` or approved download request validation, prompting unapproved users to ask for access first.
|
|
||||||
* **Download request reset**: Client request states are fully synchronized. When an admin rejects a download request, the frontend allows the user to re-submit a request by resetting the request status back to `PENDING` in the database.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. MinIO S3-Compatible Storage Migration Guide
|
|
||||||
|
|
||||||
Currently, files are uploaded to `/uploads` on the backend's local directory. To support scaling, high-performance CDN setups, and enterprise security, we will migrate the static storage engine to **MinIO** (an open-source S3-compatible object storage).
|
|
||||||
|
|
||||||
### A. MinIO Deployment Setup (Docker)
|
|
||||||
|
|
||||||
Add the MinIO service to your local developer containers or run it in the background:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
-p 9000:9000 \
|
|
||||||
-p 9001:9001 \
|
|
||||||
--name minio-storage \
|
|
||||||
-e "MINIO_ROOT_USER=admin" \
|
|
||||||
-e "MINIO_ROOT_PASSWORD=SuperSecretPassword123" \
|
|
||||||
minio/minio server /data --console-address ":9001"
|
|
||||||
```
|
|
||||||
|
|
||||||
Log in to `http://localhost:9001` and create a private bucket named `secure-assets`.
|
|
||||||
|
|
||||||
### B. Backend Node SDK Integration
|
|
||||||
|
|
||||||
1. Install AWS S3 SDK packages:
|
|
||||||
```bash
|
|
||||||
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner multer-s3
|
|
||||||
```
|
|
||||||
2. Create an S3 configuration client (`/src/utils/s3.ts`):
|
|
||||||
```typescript
|
|
||||||
import { S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
export const s3Client = new S3Client({
|
|
||||||
endpoint: process.env.MINIO_ENDPOINT || "http://localhost:9000",
|
|
||||||
region: "us-east-1", // MinIO defaults
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.MINIO_ACCESS_KEY || "admin",
|
|
||||||
secretAccessKey: process.env.MINIO_SECRET_KEY || "SuperSecretPassword123",
|
|
||||||
},
|
|
||||||
forcePathStyle: true, // Crucial for MinIO path style endpoints
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### C. Securing Pre-Signed Preview URLs
|
|
||||||
|
|
||||||
To prevent unauthorized users from sharing document links, the files inside the MinIO bucket must remain **private**.
|
|
||||||
|
|
||||||
When a user opens the preview modal:
|
|
||||||
|
|
||||||
1. The frontend requests a temporary view link from the backend.
|
|
||||||
2. The backend generates a **pre-signed URL** that expires in **5 minutes**:
|
|
||||||
```typescript
|
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
||||||
import { s3Client } from "./s3";
|
|
||||||
|
|
||||||
export const generateAssetPreviewUrl = async (fileKey: string): Promise<string> => {
|
|
||||||
const command = new GetObjectCommand({
|
|
||||||
Bucket: "secure-assets",
|
|
||||||
Key: fileKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generates a link that becomes useless after 300 seconds
|
|
||||||
return await getSignedUrl(s3Client, command, { expiresIn: 300 });
|
|
||||||
};
|
|
||||||
```
|
|
||||||
3. The frontend renders this temporary link inside the `<iframe>`. Even if the client extracts the URL from source, it will expire and become invalid shortly after.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Future Development Roadmap & Milestones
|
|
||||||
|
|
||||||
To finish the product and prepare it for production release, follow these step-by-step milestones.
|
|
||||||
|
|
||||||
### Milestone 1: MinIO Storage Migration
|
|
||||||
|
|
||||||
- [ ] Install S3 SDK on the backend.
|
|
||||||
- [ ] Replace file write helper in `asset.controller.ts` with S3 uploads using `PutObjectCommand`.
|
|
||||||
- [ ] Implement an endpoint on the backend (`/api/v1/assets/:id/preview`) to return a short-term pre-signed URL for document iframes.
|
|
||||||
|
|
||||||
### Milestone 2: Gated Legal Sign-off (NDA & MSA)
|
|
||||||
|
|
||||||
- [ ] Create a `LegalAgreementsModal` on the frontend that displays the active `LegalDocument` of type `NDA` or `MSA`.
|
|
||||||
- [ ] If a user's `onboardingStatus` is `PENDING_ONBOARDING`, block dashboard navigation and display the modal forcing them to accept.
|
|
||||||
- [ ] Create backend controllers to record acceptances with actor ID, IP address, and cryptographic hashes in the `LegalAcceptance` table.
|
|
||||||
|
|
||||||
### Milestone 3: Nested Folders & Bulk Operations
|
|
||||||
|
|
||||||
- [ ] Update the assets sidebar to display the nested `Folder` tree structure.
|
|
||||||
- [ ] Implement bulk share/revoke controllers that let admins select multiple assets and assign them to an entire organization or individual users in a single operation.
|
|
||||||
|
|
||||||
### Milestone 4: Partner Activity Timeline & Audit Logs
|
|
||||||
|
|
||||||
- [ ] Hook the `AuditLog` database table into all controller actions.
|
|
||||||
- [ ] Display an interactive activity timeline on the Admin dashboard showing:
|
|
||||||
- *Who downloaded what file and when.*
|
|
||||||
- *Organization onboardings and pending request queues.*
|
|
||||||
Loading…
Reference in New Issue
Block a user