Feat: Implement dynamic, interactive client-side document previews for Word, Excel, and CSV in AssetViewerModal

This commit is contained in:
kenilkb 2026-07-16 09:56:07 +05:30
parent 6a22f954c7
commit 63b3835ecf
3 changed files with 348 additions and 16 deletions

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

@ -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;
@ -130,10 +136,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 +192,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;
@ -507,33 +560,97 @@ 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;
}
.excel-preview-container th, .excel-preview-container td {
border: 1px solid #e2e8f0;
padding: 6px 12px;
text-align: left;
white-space: nowrap;
}
.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') ? (