Compare commits
No commits in common. "5254e347a897b8c6600d67ae9e3cb26d3f187667" and "e7f50a66674eb5e77fdfc6092b2b7252f83cb014" have entirely different histories.
5254e347a8
...
e7f50a6667
7
.gitignore
vendored
7
.gitignore
vendored
@ -29,10 +29,3 @@ uploads/*
|
||||
.next/*
|
||||
.env.local
|
||||
.vite/*
|
||||
|
||||
# Developer Documentation
|
||||
/rules.md
|
||||
/memory.md
|
||||
/phases.md
|
||||
/architecture.md
|
||||
/Guide.md
|
||||
@ -1,8 +1,6 @@
|
||||
import prisma from '../utils/db';
|
||||
import crypto from 'crypto';
|
||||
import { MailService } from './mail.service';
|
||||
import { s3Client, BUCKET_NAME } from '../utils/s3';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
export class AssetService {
|
||||
private mailService = new MailService();
|
||||
@ -379,19 +377,6 @@ export class AssetService {
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
|
||||
213
Channel-Frontend/package-lock.json
generated
213
Channel-Frontend/package-lock.json
generated
@ -13,14 +13,12 @@
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"axios": "^1.18.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"framer-motion": "^12.42.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@ -1213,15 +1211,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@ -1370,28 +1359,6 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -1423,24 +1390,6 @@
|
||||
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@ -1484,15 +1433,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -1637,15 +1577,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": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@ -1819,24 +1750,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": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.0.tgz",
|
||||
@ -1856,27 +1769,6 @@
|
||||
"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": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@ -2286,12 +2178,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -2348,12 +2234,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@ -2438,21 +2318,6 @@
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
@ -2487,12 +2352,6 @@
|
||||
"@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": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@ -2526,12 +2385,6 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -2542,27 +2395,6 @@
|
||||
"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": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
@ -2668,12 +2500,6 @@
|
||||
"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": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
|
||||
@ -2752,45 +2578,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
|
||||
@ -15,14 +15,12 @@
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"axios": "^1.18.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"framer-motion": "^12.42.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Globe,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
File,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
@ -13,23 +15,11 @@ import {
|
||||
Download,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Globe
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
import type { User } from '../../../types/auth';
|
||||
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 {
|
||||
asset: Asset;
|
||||
@ -79,34 +69,20 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
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 isOffice = type.includes('word') || type.includes('presentation') || type.includes('sheet') ||
|
||||
url.toLowerCase().endsWith('.docx') || url.toLowerCase().endsWith('.doc') ||
|
||||
url.toLowerCase().endsWith('.pptx') || url.toLowerCase().endsWith('.ppt') ||
|
||||
url.toLowerCase().endsWith('.xlsx') || url.toLowerCase().endsWith('.xls') ||
|
||||
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;
|
||||
url.toLowerCase().endsWith('.xlsx') || url.toLowerCase().endsWith('.xls');
|
||||
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg') || isOffice;
|
||||
};
|
||||
|
||||
const getFullAssetUrl = (url: string) => {
|
||||
@ -128,7 +104,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
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 isPdf = asset.type.includes('pdf') || asset.url.toLowerCase().endsWith('.pdf');
|
||||
@ -136,8 +112,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
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 hasBanner = isImage || !!asset.thumbnailUrl;
|
||||
const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
|
||||
const hasBanner = isImage || (asset.type === 'case_study' && !!asset.thumbnailUrl);
|
||||
const bannerSrc = (asset.type === 'case_study' && asset.thumbnailUrl) ? asset.thumbnailUrl : asset.url;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@ -256,304 +232,90 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : 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>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Mock Card Domain Display */}
|
||||
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none">
|
||||
<div className="w-6 h-6 rounded bg-indigo-650 flex items-center justify-center text-[10px] font-black text-white shrink-0">
|
||||
{getFriendlyHostname(asset.url).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-[10px] font-bold text-white font-mono truncate leading-none">
|
||||
{getFriendlyHostname(asset.url)}
|
||||
</div>
|
||||
<div className="text-[7px] text-slate-500 font-mono mt-0.5 leading-none">
|
||||
EXTERNAL PORTAL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{/* Fallbacks / simulated thumbnails */}
|
||||
<div
|
||||
id={`fallback-${asset.id}`}
|
||||
className="w-full h-full flex items-center justify-center bg-ink-50 relative overflow-hidden"
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={{ display: hasBanner ? 'none' : 'flex' }}
|
||||
>
|
||||
{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>
|
||||
{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>
|
||||
{/* Main slide content area */}
|
||||
<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">
|
||||
<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 className="space-y-1 mt-4">
|
||||
<div className="h-1 bg-ink-200 rounded w-5/6" />
|
||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||
<div className="h-1 bg-ink-200 rounded w-4/5" />
|
||||
</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 className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-0.5 mt-1">
|
||||
<span>PDF</span>
|
||||
<FileText className="w-2.5 h-2.5 text-red-500" />
|
||||
</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>
|
||||
) : isPresentation ? (
|
||||
<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 className="h-1.5 bg-ink-0/90 rounded w-3/4 mb-1" />
|
||||
<div className="space-y-1">
|
||||
<div className="h-1 bg-ink-0/60 rounded w-full" />
|
||||
<div className="h-1 bg-ink-0/60 rounded w-5/6" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[7px] text-ink-0/80 font-bold pt-0.5">
|
||||
<span>SLIDE</span>
|
||||
<span className="font-extrabold">PPTX</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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">
|
||||
{/* Document Margins decorative elements */}
|
||||
<div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" />
|
||||
|
||||
{/* 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 className="flex flex-col items-center justify-center w-full h-full p-4">
|
||||
<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-blue-600 text-ink-0 py-0.5 text-[8px] font-extrabold uppercase text-center tracking-wider">
|
||||
DOC
|
||||
</div>
|
||||
|
||||
{/* Page Title & Body Text Mockup */}
|
||||
<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-100 rounded w-full" />
|
||||
<div className="h-1.5 bg-slate-50 rounded w-3/4" />
|
||||
<div className="space-y-1 mt-4">
|
||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||
<div className="h-1 bg-ink-200 rounded w-5/6" />
|
||||
<div className="h-1 bg-ink-200 rounded w-full" />
|
||||
</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>
|
||||
<FileText className="w-2.5 h-2.5 text-blue-500" />
|
||||
</div>
|
||||
|
||||
{/* Signature / Metadata stamp at the bottom */}
|
||||
<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 ? (
|
||||
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden flex flex-col justify-between">
|
||||
{/* Excel Top Bar */}
|
||||
<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 className="flex flex-col items-center justify-center w-full h-full p-4">
|
||||
<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="grid grid-cols-3 gap-0.5 mt-0.5">
|
||||
<div className="h-1.5 bg-ink-0/90 rounded-sm" />
|
||||
<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>
|
||||
|
||||
{/* Sheet Grid Layout */}
|
||||
<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 className="flex items-center justify-between text-[7px] text-emerald-100 font-bold pt-0.5">
|
||||
<span>SHEET</span>
|
||||
<span className="font-extrabold">XLSX</span>
|
||||
</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 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="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="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">
|
||||
Resource File
|
||||
</div>
|
||||
<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 className="flex flex-col items-center justify-center w-full h-full p-4">
|
||||
<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">
|
||||
<Icon className="w-6 h-6 text-ink-650" />
|
||||
<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">
|
||||
{asset.type === 'case_study' ? 'CASE STUDY' : asset.type === 'url' ? 'LINK' : 'FILE'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
onClose={onClose}
|
||||
title="Asset Details"
|
||||
size="2xl"
|
||||
size="md"
|
||||
footer={
|
||||
<Button
|
||||
onClick={onClose}
|
||||
@ -42,9 +42,7 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||
}
|
||||
>
|
||||
{asset && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 text-xs font-medium text-ink-900">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 text-xs font-medium text-ink-900">
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4>
|
||||
<p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
|
||||
@ -57,19 +55,37 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(asset.type === 'case_study' || asset.thumbnailUrl) && asset.thumbnailUrl && (
|
||||
{asset.type === 'case_study' && 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">
|
||||
<div className="w-full h-40 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
|
||||
<img
|
||||
src={asset.thumbnailUrl}
|
||||
alt="Asset Banner"
|
||||
alt="Case Study Banner"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asset.type === 'case_study' && asset.problemStatement && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-red-600 font-sans mb-1">Problem / Challenge</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">
|
||||
{asset.problemStatement}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
@ -91,27 +107,6 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{asset.type === 'case_study' && asset.problemStatement && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-red-650 font-sans mb-1.5">Problem / Challenge</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">
|
||||
{asset.problemStatement}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
@ -143,7 +138,6 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@ -6,7 +6,6 @@ import type { User } from '../../../types/auth';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface AssetViewerModalProps {
|
||||
isOpen: boolean;
|
||||
@ -26,11 +25,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isLoadingText, setIsLoadingText] = useState(false);
|
||||
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) => {
|
||||
let resolvedUrl = url;
|
||||
@ -92,9 +86,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
|
||||
const isTextOrCodeAsset = (url: string, type: string) => {
|
||||
const assetUrl = url.toLowerCase();
|
||||
if (assetUrl.endsWith('.csv') || type.includes('csv')) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
assetUrl.endsWith('.md') ||
|
||||
assetUrl.endsWith('.txt') ||
|
||||
@ -139,17 +130,10 @@ 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(() => {
|
||||
if (isOpen && asset) {
|
||||
setTextPreviewContent('');
|
||||
setIsLoadingText(false);
|
||||
setDocBlob(null);
|
||||
setExcelHtml('');
|
||||
setIsLoadingDoc(false);
|
||||
setDocLoadError(false);
|
||||
|
||||
if (
|
||||
asset.type !== 'url' &&
|
||||
@ -195,49 +179,9 @@ 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]);
|
||||
|
||||
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) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
@ -285,30 +229,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
Close Preview
|
||||
</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') && (
|
||||
<Button
|
||||
onClick={() => onDownload(asset)}
|
||||
@ -355,115 +275,37 @@ 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">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4 text-ink-950" />
|
||||
<span className="font-sans">External Web Link Portal Guide</span>
|
||||
<span className="font-sans">External Web Link Preview</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.url}</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* Visual Section: Browser Mockup */}
|
||||
<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">
|
||||
{/* Browser Header Bar */}
|
||||
<div className="h-7 bg-slate-800 border-b border-slate-700/60 flex items-center px-4 gap-2 shrink-0 select-none">
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<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 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">
|
||||
{asset.thumbnailUrl ? (
|
||||
<div className="w-full h-48 border border-ink-100 rounded-xl overflow-hidden shadow-sm shrink-0">
|
||||
<img
|
||||
src={asset.thumbnailUrl}
|
||||
alt="Web Preview Banner"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</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">
|
||||
<Globe className="w-3 h-3 text-slate-500 shrink-0" />
|
||||
<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 className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none">
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-650 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md shadow-indigo-600/20">
|
||||
{(() => {
|
||||
try {
|
||||
return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase();
|
||||
} 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 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-8 h-8 text-ink-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 w-full">
|
||||
<h3 className="text-base font-extrabold text-ink-950 font-sans leading-snug">{asset.title}</h3>
|
||||
{asset.description ? (
|
||||
<p className="text-xs text-ink-500 font-sans leading-relaxed px-4">{asset.description}</p>
|
||||
) : (
|
||||
<p className="text-xs text-ink-400 font-sans italic leading-relaxed">No description provided for this external 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 className="pt-6 border-t border-ink-100 w-full mt-4">
|
||||
<div className="w-full pt-2 px-4">
|
||||
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
||||
<div className="space-y-2 w-full">
|
||||
<a
|
||||
href={asset.url}
|
||||
target="_blank"
|
||||
@ -473,16 +315,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
<span>Open Website in New Tab</span>
|
||||
<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">
|
||||
Access Restricted: You must request and receive approval from the Administrator to open this external link.
|
||||
@ -563,88 +395,23 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (isWord || isSpreadsheet || asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt')) ? (
|
||||
) : (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')) ? (
|
||||
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
||||
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<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 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">
|
||||
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
||||
<span className="font-sans">Office Document Preview</span>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
{isLocalUrl(asset.url) ? (
|
||||
<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-red-50 border border-red-100 flex items-center justify-center text-red-650 shadow-sm shrink-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">
|
||||
<FileText className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-red-950 font-sans">Failed to Render Document</h4>
|
||||
<p className="text-xs text-slate-500 mt-2 leading-relaxed font-sans">
|
||||
We encountered an error loading or rendering this document ({asset.type.split('/').pop()?.toUpperCase() || 'FILE'}).
|
||||
<h4 className="text-sm font-bold text-ink-900 font-sans">Office Document Preview</h4>
|
||||
<p className="text-xs text-ink-500 mt-2 leading-relaxed font-sans">
|
||||
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
|
||||
</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">
|
||||
Please download the asset directly to view it on your device.
|
||||
</p>
|
||||
</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>
|
||||
) : (
|
||||
/* PowerPoint / Presentation */
|
||||
isLocalUrl(asset.url) ? (
|
||||
<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-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">
|
||||
<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">
|
||||
<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>
|
||||
@ -655,7 +422,6 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
className="w-full flex-1 border-0 min-h-0"
|
||||
title={asset.title}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1,129 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1,110 +0,0 @@
|
||||
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
Normal file
186
Guide.md
Normal file
@ -0,0 +1,186 @@
|
||||
# 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