917 lines
46 KiB
TypeScript
917 lines
46 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
|
|
import { axiosInstance } from '../../../services/axios';
|
|
import type { Asset } from '../../../types/assets';
|
|
import type { User } from '../../../types/auth';
|
|
import Modal from '../../../components/ui/Modal';
|
|
import Button from '../../../components/ui/Button';
|
|
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
|
|
import * as XLSX from 'xlsx';
|
|
|
|
interface AssetViewerModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
asset: Asset | null;
|
|
user: User | null;
|
|
onDownload: (asset: Asset) => void;
|
|
}
|
|
|
|
export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
asset,
|
|
user,
|
|
onDownload
|
|
}) => {
|
|
const navigate = useNavigate();
|
|
const [isMaximized, setIsMaximized] = useState(false);
|
|
const [isLoadingText, setIsLoadingText] = useState(false);
|
|
const [textPreviewContent, setTextPreviewContent] = useState('');
|
|
const [docBlob, setDocBlob] = useState<ArrayBuffer | null>(null);
|
|
const [excelHtml, setExcelHtml] = useState<string>('');
|
|
const [isLoadingDoc, setIsLoadingDoc] = useState(false);
|
|
const [docLoadError, setDocLoadError] = useState(false);
|
|
const wordContainerRef = React.useRef<HTMLDivElement>(null);
|
|
const extractYouTubeVideoId = (url: string): string | null => {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
if (urlObj.hostname.includes('youtu.be')) {
|
|
return urlObj.pathname.slice(1).split(/[?#]/)[0];
|
|
}
|
|
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
|
|
const parts = urlObj.pathname.split('/');
|
|
return parts.pop()?.split(/[?#]/)[0] || null;
|
|
}
|
|
if (urlObj.searchParams.has('v')) {
|
|
return urlObj.searchParams.get('v');
|
|
}
|
|
} catch (e) {}
|
|
const patterns = [
|
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
|
];
|
|
for (const pattern of patterns) {
|
|
const match = url.match(pattern);
|
|
if (match) return match[1];
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getFullAssetUrl = (url: string) => {
|
|
let resolvedUrl = url;
|
|
if (!url.startsWith('http')) {
|
|
const backendBase = axiosInstance.defaults.baseURL || '/api/v1';
|
|
const relativeHost = backendBase.replace('/api/v1', '');
|
|
resolvedUrl = relativeHost.startsWith('/') || relativeHost === ''
|
|
? `${window.location.origin}${relativeHost}${url}`
|
|
: `${relativeHost}${url}`;
|
|
}
|
|
|
|
// If current origin is a public domain (like ngrok) but the resolved URL is local,
|
|
// rewrite the local URL host to match the public origin so that external viewers can fetch it.
|
|
const currentOrigin = window.location.origin;
|
|
const isCurrentOriginLocal = currentOrigin.includes('localhost') || currentOrigin.includes('127.0.0.1');
|
|
if (!isCurrentOriginLocal && (resolvedUrl.includes('localhost') || resolvedUrl.includes('127.0.0.1'))) {
|
|
resolvedUrl = resolvedUrl.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, currentOrigin);
|
|
}
|
|
|
|
return resolvedUrl;
|
|
};
|
|
|
|
const isLocalUrl = (url: string) => {
|
|
const resolved = getFullAssetUrl(url);
|
|
return (
|
|
resolved.includes('localhost') ||
|
|
resolved.includes('127.0.0.1') ||
|
|
resolved.includes('ngrok-free.dev') ||
|
|
resolved.includes('ngrok.io')
|
|
);
|
|
};
|
|
|
|
const getGithubRawUrl = (url: string) => {
|
|
let raw = url.trim();
|
|
if (raw.endsWith('.git')) {
|
|
raw = raw.slice(0, -4);
|
|
}
|
|
if (raw.endsWith('/')) {
|
|
raw = raw.slice(0, -1);
|
|
}
|
|
|
|
if (raw.includes('/blob/')) {
|
|
return raw.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/');
|
|
}
|
|
|
|
const match = raw.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
if (match && match[1] && match[2]) {
|
|
const owner = match[1];
|
|
const repo = match[2];
|
|
return {
|
|
owner,
|
|
repo,
|
|
mainUrl: `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
|
|
masterUrl: `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const isTextOrCodeAsset = (url: string, type: string) => {
|
|
const assetUrl = url.toLowerCase();
|
|
if (assetUrl.endsWith('.csv') || type.includes('csv')) {
|
|
return false;
|
|
}
|
|
return (
|
|
assetUrl.endsWith('.md') ||
|
|
assetUrl.endsWith('.txt') ||
|
|
assetUrl.endsWith('.json') ||
|
|
assetUrl.endsWith('.js') ||
|
|
assetUrl.endsWith('.ts') ||
|
|
assetUrl.endsWith('.tsx') ||
|
|
assetUrl.endsWith('.jsx') ||
|
|
assetUrl.endsWith('.py') ||
|
|
assetUrl.endsWith('.yaml') ||
|
|
assetUrl.endsWith('.yml') ||
|
|
assetUrl.endsWith('.css') ||
|
|
assetUrl.endsWith('.html') ||
|
|
assetUrl.endsWith('.sh') ||
|
|
type.includes('text') ||
|
|
type.includes('markdown') ||
|
|
type.includes('json') ||
|
|
type.includes('javascript')
|
|
);
|
|
};
|
|
|
|
const isAudioAsset = (url: string, type: string) => {
|
|
const assetUrl = url.toLowerCase();
|
|
return (
|
|
assetUrl.endsWith('.mp3') ||
|
|
assetUrl.endsWith('.wav') ||
|
|
assetUrl.endsWith('.ogg') ||
|
|
assetUrl.endsWith('.m4a') ||
|
|
assetUrl.endsWith('.webm') ||
|
|
type.includes('audio/')
|
|
);
|
|
};
|
|
|
|
const isVideoAsset = (url: string, type: string) => {
|
|
const assetUrl = url.toLowerCase();
|
|
return (
|
|
assetUrl.endsWith('.mp4') ||
|
|
assetUrl.endsWith('.webm') ||
|
|
assetUrl.endsWith('.ogg') ||
|
|
assetUrl.endsWith('.mov') ||
|
|
type.includes('video/')
|
|
);
|
|
};
|
|
|
|
const isWord = asset ? (((asset.type || '').includes('word') || (asset.url || '').toLowerCase().endsWith('.docx') || (asset.url || '').toLowerCase().endsWith('.doc'))) : false;
|
|
const isSpreadsheet = asset ? (((asset.type || '').includes('sheet') || (asset.url || '').toLowerCase().endsWith('.xlsx') || (asset.url || '').toLowerCase().endsWith('.xls') || (asset.url || '').toLowerCase().endsWith('.csv'))) : false;
|
|
|
|
useEffect(() => {
|
|
if (isOpen && asset) {
|
|
setTextPreviewContent('');
|
|
setIsLoadingText(false);
|
|
setDocBlob(null);
|
|
setExcelHtml('');
|
|
setIsLoadingDoc(false);
|
|
setDocLoadError(false);
|
|
|
|
if (
|
|
asset.type !== 'url' &&
|
|
isTextOrCodeAsset(asset.url, asset.type)
|
|
) {
|
|
setIsLoadingText(true);
|
|
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' })
|
|
.then(res => {
|
|
setTextPreviewContent(res.data);
|
|
setIsLoadingText(false);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
setTextPreviewContent('Failed to load text preview.');
|
|
setIsLoadingText(false);
|
|
});
|
|
}
|
|
else if (asset.type === 'url' && asset.url.includes('github.com')) {
|
|
const ghInfo = getGithubRawUrl(asset.url);
|
|
if (ghInfo) {
|
|
setIsLoadingText(true);
|
|
const targetUrl = typeof ghInfo === 'string' ? ghInfo : ghInfo.mainUrl;
|
|
|
|
fetch(targetUrl)
|
|
.then(async (res) => {
|
|
if (!res.ok && typeof ghInfo !== 'string') {
|
|
const fallbackRes = await fetch(ghInfo.masterUrl);
|
|
if (fallbackRes.ok) {
|
|
return fallbackRes.text();
|
|
}
|
|
throw new Error('README not found');
|
|
}
|
|
return res.text();
|
|
})
|
|
.then(data => {
|
|
setTextPreviewContent(data);
|
|
setIsLoadingText(false);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
setTextPreviewContent(`### GitHub Repository: ${typeof ghInfo === 'string' ? asset.url : `${ghInfo.owner}/${ghInfo.repo}`}\n\nCould not fetch README.md automatically. The repository might be private or not contain a README.md file.`);
|
|
setIsLoadingText(false);
|
|
});
|
|
}
|
|
}
|
|
else if (isWord || isSpreadsheet) {
|
|
setIsLoadingDoc(true);
|
|
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'arraybuffer' })
|
|
.then(res => {
|
|
const data = res.data;
|
|
setDocBlob(data);
|
|
|
|
if (isSpreadsheet) {
|
|
try {
|
|
const workbook = XLSX.read(data, { type: 'array' });
|
|
const sheetName = workbook.SheetNames[0];
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
const html = XLSX.utils.sheet_to_html(worksheet, { header: '', footer: '' });
|
|
setExcelHtml(html);
|
|
} catch (err) {
|
|
console.error("SheetJS Parsing error", err);
|
|
setDocLoadError(true);
|
|
}
|
|
}
|
|
setIsLoadingDoc(false);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
setDocLoadError(true);
|
|
setIsLoadingDoc(false);
|
|
});
|
|
}
|
|
}
|
|
}, [isOpen, asset]);
|
|
|
|
useEffect(() => {
|
|
if (isWord && docBlob && wordContainerRef.current) {
|
|
wordContainerRef.current.innerHTML = '';
|
|
import('docx-preview').then(({ renderAsync }) => {
|
|
renderAsync(docBlob, wordContainerRef.current!)
|
|
.catch(err => {
|
|
console.error("Docx Preview error:", err);
|
|
wordContainerRef.current!.innerHTML = '<div class="p-4 text-red-550 font-sans">Failed to render Word document preview. Please download the file to inspect.</div>';
|
|
});
|
|
});
|
|
}
|
|
}, [docBlob, isWord]);
|
|
|
|
const formatBytes = (bytes?: number, decimals = 2) => {
|
|
if (!bytes || isNaN(bytes) || bytes === 0) return '';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setIsMaximized(false);
|
|
onClose();
|
|
};
|
|
|
|
const handleLocateInCatalog = () => {
|
|
onClose();
|
|
if (!asset) return;
|
|
|
|
const typeLower = (asset.type || '').toLowerCase();
|
|
const titleLower = (asset.title || '').toLowerCase();
|
|
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
|
|
|
|
let targetBasePath = '';
|
|
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
|
|
targetBasePath = isCurrentAdmin ? '/admin/showcase' : '/client/showcase';
|
|
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
|
|
targetBasePath = isCurrentAdmin ? '/admin/legal' : '/client/agreements';
|
|
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
|
|
targetBasePath = isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem';
|
|
} else {
|
|
targetBasePath = isCurrentAdmin ? '/admin/assets' : '/client/assets';
|
|
}
|
|
|
|
const fullUrl = `${targetBasePath}?highlight=${asset.id}`;
|
|
navigate(fullUrl, { state: { highlightAssetId: asset.id } });
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen && !!asset}
|
|
onClose={handleClose}
|
|
title={
|
|
<div className="flex items-center justify-between w-full">
|
|
<div className="text-left">
|
|
<span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
|
|
<span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
|
|
{asset?.type === 'url' ? 'External Web Link' : asset?.size ? `${asset.type} • ${formatBytes(asset.size)}` : asset?.type}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsMaximized(!isMaximized)}
|
|
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors ml-4 cursor-pointer inline-flex items-center"
|
|
title={isMaximized ? "Collapse view" : "Expand view"}
|
|
>
|
|
{isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
}
|
|
size="full"
|
|
className={isMaximized
|
|
? '!fixed !inset-0 !z-[10001] !max-w-none !max-h-none !w-screen !h-screen !rounded-none !border-none !m-0'
|
|
: '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'
|
|
}
|
|
footer={
|
|
<>
|
|
<Button
|
|
onClick={handleClose}
|
|
variant="ghost"
|
|
size="sm"
|
|
>
|
|
Close Preview
|
|
</Button>
|
|
|
|
{asset && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
|
<Button
|
|
onClick={() => {
|
|
const url = getFullAssetUrl(asset.url);
|
|
const isOffice = asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') ||
|
|
asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') ||
|
|
asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') ||
|
|
asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls');
|
|
|
|
if (isOffice && !isLocalUrl(asset.url)) {
|
|
window.open(`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`, '_blank');
|
|
} else {
|
|
window.open(url, '_blank');
|
|
}
|
|
}}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="flex items-center gap-2"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
<span>Open in New Tab</span>
|
|
</Button>
|
|
)}
|
|
|
|
{asset && (
|
|
<Button
|
|
onClick={handleLocateInCatalog}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10"
|
|
>
|
|
<Compass className="w-4 h-4" />
|
|
<span>Locate in Portal Catalog</span>
|
|
</Button>
|
|
)}
|
|
|
|
{asset && asset.type !== 'url' && asset.type !== 'case_study' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
|
<Button
|
|
onClick={() => onDownload(asset)}
|
|
variant="primary"
|
|
size="sm"
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>Download File</span>
|
|
</Button>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
{asset && (
|
|
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0 h-full">
|
|
{asset.type === 'url' && asset.url.includes('github.com') ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
|
|
<span className="flex items-center gap-1.5">
|
|
<Globe className="w-4 h-4 text-ink-900" />
|
|
<span className="font-sans">Embedded GitHub Document</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
|
{isLoadingText ? (
|
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
|
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
|
<span className="text-xs text-ink-500 font-medium font-sans">Fetching README.md...</span>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-3xl mx-auto space-y-4">
|
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
|
<h1 className="text-xl font-extrabold text-ink-900 font-sans">{asset.title}</h1>
|
|
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
|
|
</div>
|
|
<MarkdownViewer markdown={textPreviewContent} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : asset.type === 'url' ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
|
|
<span className="flex items-center gap-1.5">
|
|
<Globe className="w-4 h-4 text-ink-900" />
|
|
<span className="font-sans">External Web Link Portal Guide</span>
|
|
</span>
|
|
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.url}</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-4 md:p-8 flex flex-col md:flex-row items-center justify-center gap-8 bg-ink-50">
|
|
{/* Visual Section: Browser Mockup */}
|
|
<div className="w-full max-w-md aspect-video md:aspect-[4/3] rounded-2xl overflow-hidden shadow-lg border border-ink-200 bg-slate-900 flex flex-col shrink-0">
|
|
{/* Browser Header Bar */}
|
|
<div className="h-7 bg-slate-800 border-b border-slate-700/60 flex items-center px-4 gap-2 shrink-0 select-none">
|
|
<div className="flex gap-1.5 shrink-0">
|
|
<span className="w-3 h-3 rounded-full bg-red-500/80" />
|
|
<span className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
|
<span className="w-3 h-3 rounded-full bg-green-500/80" />
|
|
</div>
|
|
<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-slate-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-slate-700 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-slate-700 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-slate-800 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md">
|
|
{(() => {
|
|
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-slate-800 bg-slate-100 border border-slate-200">
|
|
External Portal Link
|
|
</span>
|
|
<h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3>
|
|
{asset.description ? (
|
|
<p className="text-xs text-ink-500 font-sans mt-2 leading-relaxed">{asset.description}</p>
|
|
) : (
|
|
<p className="text-xs text-ink-400 font-sans mt-2 italic">No description provided for this external portal link.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Step-by-Step Access Guide */}
|
|
<div className="bg-ink-50 border border-ink-200 rounded-xl p-4 space-y-3">
|
|
<span className="text-[9px] font-bold uppercase tracking-wider text-ink-400 block">Access Guide</span>
|
|
<ul className="space-y-2 text-xs text-ink-700 leading-relaxed font-sans">
|
|
<li className="flex items-start gap-2">
|
|
<span className="w-4 h-4 rounded-full bg-ink-200 text-ink-800 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">1</span>
|
|
<span>Click the primary button below or select <strong>Open in New Tab</strong> from the footer to access this asset.</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="w-4 h-4 rounded-full bg-ink-200 text-ink-800 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">2</span>
|
|
<span>If the partner resource requires authorization, use your partner account credentials to sign in.</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-6 border-t border-ink-100 w-full mt-4">
|
|
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
|
<div className="space-y-2 w-full">
|
|
<a
|
|
href={asset.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all hover:scale-[1.02] active:scale-95 shadow-md w-full font-sans cursor-pointer"
|
|
>
|
|
<span>Open Website in New Tab</span>
|
|
<ExternalLink className="w-4 h-4" />
|
|
</a>
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(asset.url);
|
|
alert('Link copied to clipboard!');
|
|
}}
|
|
className="w-full text-center text-xs font-bold text-ink-600 hover:text-ink-900 bg-ink-50 hover:bg-ink-100 border border-ink-200 py-2 rounded-xl transition-all cursor-pointer"
|
|
>
|
|
Copy Link Address
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="bg-amber-50 border border-amber-200 text-amber-900 rounded-xl p-4 text-xs font-medium text-center font-sans">
|
|
Access Restricted: You must request and receive approval from the Administrator to open this external link.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : asset.type === 'case_study' || asset.type.includes('video') || asset.type === 'showcase' ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
|
|
<span className="flex items-center gap-1.5">
|
|
<Sparkles className="w-4 h-4 text-emerald-600" />
|
|
<span className="font-sans">Executive Case Study & Video Showcase</span>
|
|
</span>
|
|
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-6 md:p-8 flex flex-col items-center justify-start gap-6 bg-ink-50">
|
|
{asset.url && (asset.url.includes('youtube.com') || asset.url.includes('youtu.be')) ? (
|
|
<div className="w-full max-w-3xl aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-ink-300 shrink-0">
|
|
<iframe
|
|
src={`https://www.youtube-nocookie.com/embed/${extractYouTubeVideoId(asset.url)}?autoplay=1&rel=0`}
|
|
className="w-full h-full border-0"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-full max-w-3xl p-6 bg-slate-900 text-white rounded-2xl shadow-xl border border-slate-800 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-extrabold uppercase bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
|
Verified Showcase Reel
|
|
</span>
|
|
</div>
|
|
<h2 className="text-lg font-bold">{asset.title}</h2>
|
|
{asset.description && <p className="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">{asset.description}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<div className="w-full max-w-3xl bg-ink-0 border border-ink-200 rounded-2xl p-6 shadow-md space-y-4 text-left">
|
|
<h3 className="text-base font-extrabold text-ink-900">{asset.title}</h3>
|
|
{asset.problemStatement && (
|
|
<div className="space-y-1">
|
|
<span className="text-xs font-bold text-red-600 uppercase tracking-wider">Problem Statement</span>
|
|
<p className="text-xs text-ink-700 leading-relaxed">{asset.problemStatement}</p>
|
|
</div>
|
|
)}
|
|
{asset.solution && (
|
|
<div className="space-y-1 pt-2 border-t border-ink-150">
|
|
<span className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Implemented Technical Solution</span>
|
|
<p className="text-xs text-ink-700 leading-relaxed">{asset.solution}</p>
|
|
</div>
|
|
)}
|
|
{asset.description && !asset.problemStatement && (
|
|
<div className="space-y-1 pt-2 border-t border-ink-150">
|
|
<span className="text-xs font-bold text-ink-600 uppercase tracking-wider">Executive Overview</span>
|
|
<p className="text-xs text-ink-700 leading-relaxed whitespace-pre-wrap">{asset.description}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : asset.type.includes('pdf') ? (
|
|
<div className="w-full h-full flex flex-col min-h-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">
|
|
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
|
|
<span className="font-sans">Interactive PDF Preview</span>
|
|
</div>
|
|
<iframe
|
|
src={getFullAssetUrl(asset.url)}
|
|
className="w-full flex-1 border-0 min-h-0"
|
|
title={asset.title}
|
|
/>
|
|
</div>
|
|
) : isTextOrCodeAsset(asset.url, asset.type) ? (
|
|
<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">
|
|
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
|
|
<span className="font-sans">Document Reader</span>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
|
{isLoadingText ? (
|
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
|
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
|
<span className="text-xs text-ink-500 font-medium font-sans">Loading content...</span>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-3xl mx-auto space-y-4">
|
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
|
<h1 className="text-xl font-extrabold text-ink-900 font-sans">{asset.title}</h1>
|
|
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Source Code Format</p>
|
|
</div>
|
|
{asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? (
|
|
<MarkdownViewer markdown={textPreviewContent} />
|
|
) : (
|
|
<pre className="whitespace-pre-wrap font-mono text-xs text-ink-800 bg-ink-50 border border-ink-200 rounded-lg p-4 break-words leading-relaxed overflow-x-auto">
|
|
{textPreviewContent}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : isAudioAsset(asset.url, asset.type) ? (
|
|
<div className="w-full h-full flex flex-col justify-center items-center bg-ink-0 p-8 space-y-4">
|
|
<div className="w-16 h-16 rounded-full bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
|
|
<span className="text-2xl">🎵</span>
|
|
</div>
|
|
<div className="text-center max-w-sm">
|
|
<h4 className="text-sm font-bold text-ink-900 font-sans">Audio Player</h4>
|
|
<p className="text-xs text-ink-500 mt-1 font-sans">You can listen to this audio asset directly in the browser.</p>
|
|
</div>
|
|
<audio
|
|
src={getFullAssetUrl(asset.url)}
|
|
controls
|
|
className="w-full max-w-md mt-4 focus:outline-none"
|
|
/>
|
|
</div>
|
|
) : isVideoAsset(asset.url, asset.type) ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-900">
|
|
<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 w-full shrink-0">
|
|
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
|
|
<span className="font-sans text-ink-900">Video Player</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center min-h-0 relative p-4">
|
|
<video
|
|
src={getFullAssetUrl(asset.url)}
|
|
controls
|
|
className="max-w-full max-h-full rounded-xl shadow-lg border border-ink-800"
|
|
style={{ outline: 'none' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (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="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
|
|
<span className="flex items-center gap-1.5">
|
|
<FileText className="w-4 h-4 text-ink-900" />
|
|
<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>
|
|
|
|
{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="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" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-red-950 font-sans">Failed to Render Document</h4>
|
|
<p className="text-xs text-slate-500 mt-2 leading-relaxed font-sans">
|
|
We encountered an error loading or rendering this document ({asset.type.split('/').pop()?.toUpperCase() || 'FILE'}).
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-2 leading-relaxed bg-slate-50 border border-slate-100 rounded-xl p-3 text-left font-sans">
|
|
Please download the asset directly to view it on your device.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : isSpreadsheet ? (
|
|
<div className="flex-1 overflow-auto bg-slate-100 p-4 flex flex-col">
|
|
<div className="excel-preview-container flex-1 bg-white border border-slate-200 shadow-sm rounded-xl overflow-auto p-4">
|
|
<style>{`
|
|
.excel-preview-container table {
|
|
border-collapse: collapse;
|
|
width: max-content;
|
|
min-width: 100%;
|
|
font-size: 11px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
color: #0f172a;
|
|
}
|
|
.excel-preview-container th, .excel-preview-container td {
|
|
border: 1px solid #e2e8f0;
|
|
padding: 6px 12px;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
color: #0f172a;
|
|
}
|
|
.excel-preview-container tr:nth-child(even) {
|
|
background-color: #f8fafc;
|
|
}
|
|
.excel-preview-container tr:hover {
|
|
background-color: #f1f5f9;
|
|
}
|
|
`}</style>
|
|
<div dangerouslySetInnerHTML={{ __html: excelHtml || '<div class="p-4 text-slate-400 font-sans italic">No data found in this spreadsheet.</div>' }} />
|
|
</div>
|
|
</div>
|
|
) : isWord ? (
|
|
<div className="flex-1 overflow-auto bg-slate-100 p-2 sm:p-6 md:p-8">
|
|
<style>{`
|
|
.docx-wrapper {
|
|
background: transparent !important;
|
|
padding: 0 !important;
|
|
display: flex !important;
|
|
flex-direction: column !important;
|
|
align-items: center !important;
|
|
}
|
|
.docx {
|
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
|
|
border: 1px solid #e2e8f0 !important;
|
|
border-radius: 12px !important;
|
|
max-width: 100% !important;
|
|
margin: 0 auto 24px auto !important;
|
|
background-color: white !important;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.docx {
|
|
padding: 16px !important;
|
|
margin-bottom: 12px !important;
|
|
}
|
|
}
|
|
`}</style>
|
|
<div
|
|
ref={wordContainerRef}
|
|
className="w-full max-w-4xl mx-auto"
|
|
/>
|
|
</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.
|
|
</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>
|
|
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
|
<div className="w-full h-full flex items-center justify-center p-4">
|
|
<img
|
|
src={getFullAssetUrl(asset.url)}
|
|
alt={asset.title}
|
|
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
|
|
/>
|
|
</div>
|
|
) : (asset.type === 'case_study' || asset.problemStatement || asset.solution) ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
|
|
<div className="max-w-4xl mx-auto w-full space-y-6">
|
|
{asset.thumbnailUrl && (
|
|
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
|
|
<img src={getFullAssetUrl(asset.thumbnailUrl)} alt={asset.title} className="w-full h-full object-cover" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-b border-ink-200 pb-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-600 border border-emerald-500/20">
|
|
Executive Case Study Showcase
|
|
</span>
|
|
{asset.type && (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-ink-100 text-ink-700 capitalize">
|
|
{asset.type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h1 className="text-xl sm:text-2xl font-extrabold text-ink-900 leading-tight">{asset.title}</h1>
|
|
{asset.description && <p className="text-xs text-ink-600 mt-2 leading-relaxed">{asset.description}</p>}
|
|
</div>
|
|
|
|
{/* Problem Statement Block */}
|
|
{asset.problemStatement && (
|
|
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 space-y-2">
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-amber-500" />
|
|
Problem Statement & Challenge
|
|
</h3>
|
|
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
|
|
<MarkdownViewer markdown={asset.problemStatement} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Proposed Solution Block */}
|
|
{asset.solution && (
|
|
<div className="p-4 rounded-xl bg-emerald-500/5 border border-emerald-500/20 space-y-2">
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-emerald-500" />
|
|
Implemented Technical Solution
|
|
</h3>
|
|
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
|
|
<MarkdownViewer markdown={asset.solution} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{asset.url && asset.url.startsWith('http') && (
|
|
<div className="pt-2 flex justify-end">
|
|
<a
|
|
href={asset.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-4 py-2 bg-ink-900 text-ink-0 hover:bg-ink-800 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Visit Showcase Webpage
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : asset.type === 'url' ? (
|
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
|
|
<div className="max-w-3xl mx-auto w-full space-y-6 text-center flex flex-col items-center justify-center h-full">
|
|
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600">
|
|
<Globe className="w-8 h-8" />
|
|
</div>
|
|
<div>
|
|
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500/10 text-blue-600 border border-blue-500/20">
|
|
External Web Resource
|
|
</span>
|
|
<h2 className="text-xl font-extrabold text-ink-900 mt-3">{asset.title}</h2>
|
|
{asset.description && <p className="text-xs text-ink-600 mt-2 max-w-lg mx-auto">{asset.description}</p>}
|
|
</div>
|
|
|
|
<div className="p-4 bg-ink-50 border border-ink-200 rounded-xl w-full max-w-md text-left">
|
|
<p className="text-[10px] font-mono text-ink-500 truncate">{asset.url}</p>
|
|
</div>
|
|
|
|
<a
|
|
href={asset.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-md transition-all cursor-pointer"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Launch Web Document
|
|
</a>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center p-8 flex flex-col justify-center items-center">
|
|
<File className="w-12 h-12 text-ink-300 mb-3" />
|
|
<h4 className="text-sm font-bold text-ink-900 font-sans">Direct Preview Unsupported</h4>
|
|
<p className="text-xs text-ink-500 mt-1 max-w-sm font-sans">
|
|
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
};
|