Compare commits

...

14 Commits

Author SHA1 Message Date
kenilkb
5254e347a8 Docs: Untrack documentation markdown files and add them to gitignore 2026-07-16 10:57:29 +05:30
kenilkb
643bb1ce68 Docs: Create project rules.md, architecture.md, memory.md, and phases.md documentation 2026-07-16 10:54:19 +05:30
kenilkb
2d730c8389 Fix: Add CSV to card isRenderable preview check and resolve dark mode white text color inheritance in spreadsheet grids 2026-07-16 10:39:08 +05:30
kenilkb
cf21405c53 Fix: Make Docx and Spreadsheet thumbnails scale responsively to full bleed width 2026-07-16 10:26:38 +05:30
kenilkb
7c6eebb151 Fix: Exclude CSV files from text preview loader so they render as interactive spreadsheets 2026-07-16 10:09:29 +05:30
kenilkb
4da31c52c0 Feat: Implement S3 object deletion when deleting an asset record 2026-07-16 10:00:34 +05:30
kenilkb
6d694efeb0 Feat: Add client-side dynamic thumbnail generators for DOCX and Spreadsheet assets 2026-07-16 09:58:39 +05:30
kenilkb
63b3835ecf Feat: Implement dynamic, interactive client-side document previews for Word, Excel, and CSV in AssetViewerModal 2026-07-16 09:56:07 +05:30
kenilkb
6a22f954c7 Feat: Implement dynamic, full-bleed mockup previews for presentation, spreadsheet, and word documents 2026-07-16 09:48:52 +05:30
kenilkb
e6d3d07fd4 Feat: Redesign URL asset viewer preview with browser mockup & access guide 2026-07-16 09:41:22 +05:30
kenilkb
c32c4b9499 Feat: Implement dynamic webpage screenshot thumbnails for URL assets 2026-07-16 09:31:03 +05:30
kenilkb
5ec1faa1f9 Style: Anchor PDF thumbnail to the top using object-top 2026-07-16 09:29:12 +05:30
kenilkb
33e6a658f4 Feat: Add dynamic client-side PDF first-page thumbnail rendering using PDF.js 2026-07-16 09:25:11 +05:30
kenilkb
ea7d688f7c Refactor: Add Open in New Tab for PDFs/Docs, upgrade details modal to 2-column lg, and enhance document cover thumbnails 2026-07-15 18:23:19 +05:30
11 changed files with 1253 additions and 401 deletions

7
.gitignore vendored
View File

@ -29,3 +29,10 @@ uploads/*
.next/* .next/*
.env.local .env.local
.vite/* .vite/*
# Developer Documentation
/rules.md
/memory.md
/phases.md
/architecture.md
/Guide.md

View File

@ -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 } });
} }

View File

@ -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",

View File

@ -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"
}, },

View File

@ -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>
{/* Fallbacks / simulated thumbnails */} {/* Browser Body Web Mockup */}
<div <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">
id={`fallback-${asset.id}`} {/* Decorative Grid Pattern */}
className="w-full h-full flex items-center justify-center" <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]" />
style={{ display: hasBanner ? 'none' : 'flex' }}
> {/* Mock Web Page Hero Abstract Layout */}
{isPdf ? ( <div className="space-y-2 mt-1.5 relative z-10">
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative"> {/* Mock Navbar */}
<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="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5">
<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"> <div className="w-8 h-1.5 bg-indigo-400/30 rounded" />
PDF <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> </div>
<div className="space-y-1 mt-4"> {/* Mock Page Content */}
<div className="h-1 bg-ink-200 rounded w-5/6" /> <div className="space-y-1 pt-1">
<div className="h-1 bg-ink-200 rounded w-full" /> <div className="h-2.5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded w-3/4" />
<div className="h-1 bg-ink-200 rounded w-4/5" /> <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>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-0.5 mt-1"> </div>
<span>PDF</span>
<FileText className="w-2.5 h-2.5 text-red-500" /> {/* 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>
</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">

View File

@ -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>

View File

@ -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">
{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="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"> <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">
<h3 className="text-base font-extrabold text-ink-950 font-sans leading-snug">{asset.title}</h3> {/* Visual Section: Browser Mockup */}
{asset.description ? ( <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">
<p className="text-xs text-ink-500 font-sans leading-relaxed px-4">{asset.description}</p> {/* 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">
<p className="text-xs text-ink-400 font-sans italic leading-relaxed">No description provided for this external link.</p> <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>
<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> </div>
<div className="w-full pt-2 px-4"> {/* 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>
{/* 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">
{(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') ? (

View File

@ -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>
);
};

View 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>
);
};

View File

@ -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
View File

@ -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 &amp; Vision](#1-project-overview--vision)
2. [Database Schema (Prisma Models)](#2-database-schema-prisma-models)
3. [Completed Implementations &amp; 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 &amp; 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.*