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
{/* Fallbacks / simulated thumbnails */} url={getFullAssetUrl(asset.url)}
<div title={asset.title}
id={`fallback-${asset.id}`} fallback={
className="w-full h-full flex items-center justify-center" <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">
style={{ display: hasBanner ? 'none' : 'flex' }} <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">
{isPdf ? ( PDF Document
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative">
<div className="w-14 h-18 bg-ink-0 border border-ink-200 shadow-sm rounded flex flex-col justify-between p-1.5 relative overflow-hidden">
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-0.5 text-[8px] font-extrabold uppercase text-center tracking-wider">
PDF
</div> </div>
<div className="space-y-1 mt-4"> <div className="space-y-1.5 mt-6 flex-grow pt-1">
<div className="h-1 bg-ink-200 rounded w-5/6" /> <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-200 rounded w-full" />
<div className="h-1 bg-ink-200 rounded w-4/5" /> <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>
<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 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</span> <span>PDF RESOURCE</span>
<FileText className="w-2.5 h-2.5 text-red-500" /> <FileText className="w-3 h-3 text-red-650" />
</div>
</div>
</div>
) : 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> </div>
</div> </div>
}
/>
) : isWord ? ( ) : isWord ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4"> <DocxThumbnail
<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"> url={getFullAssetUrl(asset.url)}
<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"> fallback={
DOC <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>
<div className="space-y-1 mt-4"> <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">
<div className="h-1 bg-ink-200 rounded w-5/6" /> {asset.title}
<div className="h-1 bg-ink-200 rounded w-full" /> </h4>
</div> <div className="space-y-1.5">
<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 className="h-1.5 bg-slate-100 rounded w-full" />
<span>WORD</span> <div className="h-1.5 bg-slate-100 rounded w-11/12" />
<FileText className="w-2.5 h-2.5 text-blue-500" /> <div className="h-1.5 bg-slate-55 rounded w-3/4" />
</div> </div>
</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>
</div>
}
/>
) : isSpreadsheet ? ( ) : isSpreadsheet ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4"> <SpreadsheetThumbnail
<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"> url={getFullAssetUrl(asset.url)}
<div className="grid grid-cols-3 gap-0.5 mt-0.5"> fallback={
<div className="h-1.5 bg-ink-0/90 rounded-sm" /> <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-1.5 bg-ink-0/70 rounded-sm" /> <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="h-1.5 bg-ink-0/70 rounded-sm" /> <span className="text-[8px] font-extrabold text-white tracking-wider">Spreadsheet Editor</span>
<div className="h-1.5 bg-ink-0/60 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/80 rounded-sm" /> </div>
<div className="h-1.5 bg-ink-0/60 rounded-sm" /> <div className="flex-1 flex flex-col min-h-0 bg-white font-mono text-[6px]">
<div className="h-4 bg-slate-100 border-b border-slate-200 flex shrink-0">
<div className="w-6 border-r border-slate-200 bg-slate-150 flex items-center justify-center text-slate-500 font-bold shrink-0"></div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">A</div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">B</div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">C</div>
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold">D</div>
</div>
<div className="flex-1 flex flex-col divide-y divide-slate-100">
<div className="h-4 flex items-center bg-emerald-500/5 divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">1</div>
<div className="flex-1 px-1 font-bold text-emerald-900 truncate">
{asset.title}
</div>
</div>
<div className="h-4 flex items-center bg-slate-55 divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">2</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Category</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Date</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Size</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Status</div>
</div>
</div>
</div>
<div className="h-4 bg-slate-50 border-t border-slate-200 flex items-center px-2 shrink-0 select-none justify-between">
<div className="flex gap-1">
<span className="text-[6px] font-extrabold text-emerald-700 bg-white border-x border-t border-slate-200 px-1.5 py-0.5 rounded-t select-none leading-none">Sheet1</span>
<span className="text-[6px] text-slate-400 px-1 py-0.5 select-none leading-none">Sheet2</span>
</div>
<span className="text-[5px] text-slate-350 font-bold">READY</span>
</div>
</div>
}
/>
) : asset.type === 'url' ? (
<div className="w-full h-full flex flex-col bg-slate-900 border border-slate-800 relative overflow-hidden group/browser">
{/* Browser Header Bar */}
<div className="h-6 bg-slate-800/80 border-b border-slate-700/50 flex items-center px-3 gap-2 shrink-0 select-none">
{/* Windows Dots */}
<div className="flex gap-1.5 shrink-0">
<span className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
<span className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
</div>
{/* URL Bar */}
<div className="flex-1 max-w-[150px] mx-auto bg-slate-900/60 border border-slate-700/30 rounded px-2 py-0.5 flex items-center gap-1 text-[9px] text-slate-400 font-mono select-none truncate">
<Globe className="w-2.5 h-2.5 text-slate-500 shrink-0" />
<span className="truncate">{getFriendlyHostname(asset.url)}</span>
</div>
</div>
{/* Browser Body Web Mockup */}
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-3 flex flex-col justify-between relative overflow-hidden">
{/* Decorative Grid Pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" />
{/* Mock Web Page Hero Abstract Layout */}
<div className="space-y-2 mt-1.5 relative z-10">
{/* Mock Navbar */}
<div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5">
<div className="w-8 h-1.5 bg-indigo-400/30 rounded" />
<div className="flex gap-1">
<div className="w-4 h-1 bg-white/20 rounded" />
<div className="w-4 h-1 bg-white/20 rounded" />
</div>
</div>
{/* Mock Page Content */}
<div className="space-y-1 pt-1">
<div className="h-2.5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded w-3/4" />
<div className="h-1.5 bg-white/20 rounded w-11/12" />
<div className="h-1.5 bg-white/10 rounded w-5/6" />
</div>
</div>
{/* 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 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> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center w-full h-full p-4"> <div
<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"> id={`fallback-${asset.id}`}
<Icon className="w-6 h-6 text-ink-650" /> className="w-full h-full flex items-center justify-center bg-ink-50 relative overflow-hidden"
<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'} {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>
{/* 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>
<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>
) : 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>
{/* 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>
</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>
{/* 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>
{/* 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> </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,7 +42,9 @@ 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">
{/* Left Column */}
<div className="space-y-4">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4> <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> <p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
@ -55,37 +57,19 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
</div> </div>
)} )}
{asset.type === 'case_study' && asset.thumbnailUrl && ( {(asset.type === 'case_study' || asset.thumbnailUrl) && asset.thumbnailUrl && (
<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 mb-1.5">Banner Image</h4>
<div className="w-full h-40 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50"> <div className="w-full h-44 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
<img <img
src={asset.thumbnailUrl} src={asset.thumbnailUrl}
alt="Case Study Banner" alt="Asset Banner"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</div> </div>
</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 className="grid grid-cols-2 gap-4">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
@ -107,6 +91,27 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
</div> </div>
</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 && ( {asset.tags.length > 0 && (
<div> <div>
@ -138,6 +143,7 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
</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,37 +355,115 @@ 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 */}
<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>
<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 ? ( {asset.description ? (
<p className="text-xs text-ink-500 font-sans leading-relaxed px-4">{asset.description}</p> <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 italic leading-relaxed">No description provided for this external link.</p> <p className="text-xs text-ink-400 font-sans mt-2 italic">No description provided for this external portal link.</p>
)} )}
</div> </div>
<div className="w-full pt-2 px-4"> {/* 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') ? (
<div className="space-y-2 w-full">
<a <a
href={asset.url} href={asset.url}
target="_blank" target="_blank"
@ -315,6 +473,16 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<span>Open Website in New Tab</span> <span>Open Website in New Tab</span>
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </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,23 +563,88 @@ 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">
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">
<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. <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> </p>
</div> </div>
@ -422,6 +655,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
className="w-full flex-1 border-0 min-h-0" className="w-full flex-1 border-0 min-h-0"
title={asset.title} 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.*