// Primus IQ β Minimalist tagging modal
// Triggered after files land via "Add sources".
// Layout: file queue on the LEFT, tag panel on the RIGHT, single "Save" CTA.
const SAMPLE_DROPPED = [
{ id: 'd1', name: 'GCC_Market_Sizing_v4.pptx', size: '12.0 MB', ext: 'pptx', suggest: 'strict', pages: 28, tags: ['GCC', 'Sizing'] },
{ id: 'd2', name: 'Acme_Financial_Model.xlsx', size: '4.2 MB', ext: 'xlsx', suggest: 'strict', pages: 12, tags: ['Acme', 'Finance'] },
{ id: 'd3', name: 'IndustryReport_Q4.pdf', size: '8.6 MB', ext: 'pdf', suggest: 'public', pages: 64, tags: ['Research'] },
{ id: 'd4', name: 'Interview_Notes_CFO.docx', size: '120 KB', ext: 'docx', suggest: 'client', pages: 4, tags: ['Acme', 'Interview'] },
{ id: 'd5', name: 'ESG_Compliance_Brief.pdf', size: '2.1 MB', ext: 'pdf', suggest: 'internal', pages: 18, tags: ['ESG'] },
{ id: 'd6', name: 'Pitch_Storyline_draft.pptx', size: '6.8 MB', ext: 'pptx', suggest: 'internal', pages: 22, tags: ['Pitch'] },
{ id: 'd7', name: 'Supply_chain_diagram.png', size: '880 KB', ext: 'png', suggest: 'internal', pages: 1, tags: ['Diagram'] },
{ id: 'd8', name: 'Bid_Cost_Calculator.xlsx', size: '1.4 MB', ext: 'xlsx', suggest: 'strict', pages: 8, tags: ['RFP'] },
{ id: 'd9', name: 'Client_brief_v2.docx', size: '340 KB', ext: 'docx', suggest: 'client', pages: 6, tags: ['Acme', 'Brief'] },
{ id: 'd10', name: 'Site_visit_video.mp4', size: '48.2 MB', ext: 'mp4', suggest: 'internal', pages: 0, tags: ['Field'] },
];
const CONF_OPTIONS = [
{ id: 'public', label: 'Public', desc: 'Anyone in firm' },
{ id: 'internal', label: 'Internal', desc: 'Logged-in users' },
{ id: 'client', label: 'Client', desc: 'Project members' },
{ id: 'strict', label: 'Strict', desc: 'Named ACL only' },
];
const SUGGESTED_TAGS = ['GCC', 'KSA', 'UAE', 'Fintech', 'Climate', 'Policy', 'Healthcare', 'TCFD', 'CSRD', 'Acme', 'Globex'];
const TaggingModal = ({ open, onClose, onCommit, pendingFiles = [] }) => {
const [files, setFiles] = React.useState([]);
const [active, setActive] = React.useState(0);
const [tagInput, setTagInput] = React.useState('');
React.useEffect(() => {
if (open) {
// Use real files when provided; fall back to sample only for demo/empty state
setFiles(pendingFiles.length > 0
? pendingFiles
: SAMPLE_DROPPED.map(f => ({ ...f, conf: f.suggest })));
setActive(0);
setTagInput('');
}
}, [open, pendingFiles]);
const f = files[active];
const updateFile = (idx, patch) => {
setFiles(prev => prev.map((file, i) => i === idx ? { ...file, ...patch } : file));
};
const addTag = (t) => {
const tag = t.trim();
if (!tag) return;
if (f.tags.includes(tag)) return;
updateFile(active, { tags: [...f.tags, tag] });
setTagInput('');
};
const removeTag = (t) => updateFile(active, { tags: f.tags.filter(x => x !== t) });
const applyAllConf = (confId) => setFiles(prev => prev.map(file => ({ ...file, conf: confId })));
React.useEffect(() => {
if (!open) return;
const onKey = (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowDown') setActive(a => Math.min(a + 1, files.length - 1));
if (e.key === 'ArrowUp') setActive(a => Math.max(a - 1, 0));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, files]);
if (!open) return null;
// Guard: nothing to tag yet
if (files.length === 0) return (
e.stopPropagation()} style={{
padding: '40px 48px', background: 'var(--bg-card)', borderRadius: 18,
boxShadow: '0 30px 80px rgba(26,22,20,0.30)', textAlign: 'center',
}}>
π
No files selected
Drop files on the zone or use the Upload button.
);
const tagged = files.filter(x => x.conf).length;
return (
e.stopPropagation()} style={{
width: 1040, maxWidth: '94%', height: 640, maxHeight: '92%',
background: 'var(--bg-card)', borderRadius: 18,
boxShadow: '0 30px 80px rgba(26,22,20,0.30), 0 0 0 1px var(--line)',
animation: 'rise 0.22s ease-out',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
{/* Header */}
Tag your files
{files.length} files Β· pick visibility and add tags Β· {tagged}/{files.length} ready
{/* Body */}
{/* LEFT β file queue */}
Files
{active + 1} / {files.length}
{files.map((file, i) => {
const isActive = i === active;
return (
setActive(i)} style={{
padding: 10, borderRadius: 9, cursor: 'pointer',
background: isActive ? 'white' : 'transparent',
border: isActive ? '1px solid var(--maroon-20)' : '1px solid transparent',
boxShadow: isActive ? '0 2px 8px rgba(60,30,20,0.06)' : 'none',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'all 0.12s',
}}>
{file.name}
{file.size}
{file.conf && {file.conf}}
);
})}
{/* RIGHT β tag panel */}
{/* File header */}
{f.name}
{f.size}
{f.pages > 0 &&
Β· {f.pages} pages}
Suggested: {f.suggest}
{/* Visibility */}
Visibility
{CONF_OPTIONS.map(opt => {
const sel = f.conf === opt.id;
return (
);
})}
{/* Tags */}
Tags
{f.tags.map(t => (
{t}
))}
setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); addTag(tagInput); }
if (e.key === 'Backspace' && !tagInput && f.tags.length > 0) removeTag(f.tags[f.tags.length - 1]);
}}
placeholder={f.tags.length === 0 ? 'Add a tagβ¦' : ''}
style={{
flex: 1, minWidth: 100, border: 'none', outline: 'none',
fontSize: 13, fontFamily: 'inherit', color: 'var(--ink)', background: 'transparent',
padding: '4px 2px',
}}
/>
Suggested:
{SUGGESTED_TAGS.filter(t => !f.tags.includes(t)).slice(0, 7).map(t => (
))}
{/* Footer banner */}
Tip: {' '}
Use β β to move between files, Enter to add a tag.
{/* Footer */}
);
};
const sectionLabel = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
fontSize: 10, fontWeight: 700, color: 'var(--mute)',
letterSpacing: '0.08em', textTransform: 'uppercase',
marginBottom: 8,
};
const miniBtn = {
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, color: 'var(--maroon)', fontFamily: 'inherit', fontWeight: 600,
textTransform: 'none', letterSpacing: 0,
};
const chipTagStyle = {
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 4px 4px 10px', borderRadius: 6,
background: 'var(--maroon-06)', color: 'var(--maroon)',
fontSize: 12, fontWeight: 500, fontFamily: 'inherit',
};
const chipRemoveBtn = {
width: 16, height: 16, borderRadius: 4, border: 'none', cursor: 'pointer',
background: 'transparent', color: 'var(--maroon)',
display: 'grid', placeItems: 'center',
};
const suggestedTagBtn = {
padding: '4px 10px', borderRadius: 999,
background: 'white', border: '1px dashed var(--line-2)',
fontSize: 11, color: 'var(--mute)', fontFamily: 'inherit', cursor: 'pointer',
};
const navBtn = {
height: 32, padding: '0 12px', borderRadius: 7,
background: 'transparent', border: '1px solid var(--line-2)',
fontSize: 12, fontFamily: 'inherit', color: 'var(--ink-2)', cursor: 'pointer',
};
const kbdStyle = {
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
background: 'white', border: '1px solid var(--line-2)',
fontSize: 10, fontFamily: 'ui-monospace, monospace', color: 'var(--ink)',
};
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SharePoint / OneDrive picker β cascading source β library β file browse.
// On confirm, returns selected files shaped for TaggingModal (sharepoint metadata,
// no fileObject). The backend downloads each file from Graph and stores it in GCS.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const spFmtSize = (bytes) => {
const b = bytes || 0;
return b < 1024 ? `${b} B`
: b < 1048576 ? `${(b / 1024).toFixed(1)} KB`
: `${(b / 1048576).toFixed(1)} MB`;
};
const SharePointPickerModal = ({ open, onClose, onConfirm }) => {
const [tab, setTab] = React.useState('onedrive'); // 'onedrive' | 'sharepoint'
const [siteQuery, setSiteQuery] = React.useState('');
const [sites, setSites] = React.useState([]);
const [selectedSite, setSelectedSite] = React.useState(null);
const [drives, setDrives] = React.useState([]);
const [drive, setDrive] = React.useState(null); // active drive being browsed
const [path, setPath] = React.useState([]); // breadcrumb: [{id, name}]
const [items, setItems] = React.useState([]);
const [selected, setSelected] = React.useState({}); // itemId -> {drive_id, item_id, name, size}
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState('');
const reset = () => {
setTab('onedrive'); setSiteQuery(''); setSites([]); setSelectedSite(null);
setDrives([]); setDrive(null); setPath([]); setItems([]); setSelected({});
setLoading(false); setError('');
};
React.useEffect(() => { if (open) reset(); }, [open]);
const fail = (ex) => { setError(ex && ex.message ? ex.message : 'Something went wrong'); setLoading(false); };
// ββ OneDrive: open the user's drive directly ββ
const openOneDrive = async () => {
setTab('onedrive'); setSelectedSite(null); setDrives([]); setError('');
setLoading(true);
try {
const d = await PrimusAPI.spOneDrive();
setDrive(d);
setPath([{ id: null, name: d.name || 'OneDrive' }]);
const its = await PrimusAPI.spListItems(d.id, null);
setItems(its || []); setLoading(false);
} catch (ex) { fail(ex); }
};
// ββ SharePoint: search sites ββ
const runSiteSearch = async () => {
setLoading(true); setError('');
try {
const res = await PrimusAPI.spSearchSites(siteQuery);
setSites(res || []); setLoading(false);
} catch (ex) { fail(ex); }
};
const pickSite = async (site) => {
setSelectedSite(site); setDrive(null); setPath([]); setItems([]); setError('');
setLoading(true);
try {
const ds = await PrimusAPI.spListDrives(site.id);
setDrives(ds || []); setLoading(false);
} catch (ex) { fail(ex); }
};
const pickDrive = async (d) => {
setDrive(d); setPath([{ id: null, name: d.name }]); setError('');
setLoading(true);
try {
const its = await PrimusAPI.spListItems(d.id, null);
setItems(its || []); setLoading(false);
} catch (ex) { fail(ex); }
};
const openFolder = async (folder) => {
setError(''); setLoading(true);
try {
const its = await PrimusAPI.spListItems(drive.id, folder.id);
setPath(prev => [...prev, { id: folder.id, name: folder.name }]);
setItems(its || []); setLoading(false);
} catch (ex) { fail(ex); }
};
const goToCrumb = async (idx) => {
const crumb = path[idx];
setError(''); setLoading(true);
try {
const its = await PrimusAPI.spListItems(drive.id, crumb.id);
setPath(prev => prev.slice(0, idx + 1));
setItems(its || []); setLoading(false);
} catch (ex) { fail(ex); }
};
const toggleFile = (item) => {
setSelected(prev => {
const next = { ...prev };
if (next[item.id]) delete next[item.id];
else next[item.id] = { drive_id: drive.id, item_id: item.id, name: item.name, size: item.size };
return next;
});
};
const selectedList = Object.values(selected);
const confirm = () => {
if (selectedList.length === 0) return;
onConfirm(selectedList);
};
if (!open) return null;
const showBrowser = !!drive;
const showSiteList = tab === 'sharepoint' && !selectedSite;
const showDriveList = tab === 'sharepoint' && selectedSite && !drive;
return (
e.stopPropagation()} style={{
width: 920, maxWidth: '94%', height: 620, maxHeight: '92%',
background: 'var(--bg-card)', borderRadius: 18,
boxShadow: '0 30px 80px rgba(26,22,20,0.30), 0 0 0 1px var(--line)',
animation: 'rise 0.22s ease-out',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
{/* Header */}
Add from Microsoft 365
Browse your OneDrive or SharePoint and pick files to import.
{/* Source tabs */}
{/* Body */}
{/* Site search */}
{showSiteList && (
setSiteQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') runSiteSearch(); }}
placeholder="Search SharePoint sites by nameβ¦"
style={{
flex: 1, height: 38, padding: '0 12px', borderRadius: 9,
border: '1px solid var(--line-2)', fontSize: 13, fontFamily: 'inherit',
outline: 'none', color: 'var(--ink)', background: 'white',
}}
/>
)}
{/* Breadcrumbs when browsing */}
{showBrowser && (
{selectedSite && (
<>
/
>
)}
{path.map((c, i) => (
{i > 0 && /}
))}
)}
{error && (
{error}
)}
{/* List area */}
{loading &&
Loadingβ¦
}
{/* Site results */}
{!loading && showSiteList && sites.length === 0 &&
Search for a site to begin.
}
{!loading && showSiteList && sites.map(s => (
pickSite(s)} style={spRow}>
{s.display_name}
{s.web_url &&
{s.web_url}
}
βΊ
))}
{/* Drive (library) list */}
{!loading && showDriveList && drives.length === 0 &&
No document libraries on this site.
}
{!loading && showDriveList && drives.map(d => (
pickDrive(d)} style={spRow}>
βΊ
))}
{/* File / folder browser */}
{!loading && showBrowser && items.length === 0 &&
This folder is empty.
}
{!loading && showBrowser && items.map(it => {
const checked = !!selected[it.id];
if (it.is_folder) {
return (
openFolder(it)} style={spRow}>
βΊ
);
}
return (
toggleFile(it)} style={{ ...spRow, background: checked ? 'var(--maroon-06)' : 'transparent' }}>
{it.name}
{spFmtSize(it.size)}
);
})}
{/* Footer */}
{selectedList.length > 0 ? `${selectedList.length} file${selectedList.length > 1 ? 's' : ''} selected` : 'No files selected'}
);
};
const SpIcon = ({ kind }) => {
const color = kind === 'site' ? 'var(--maroon)' : kind === 'library' ? 'var(--gold)' : kind === 'folder' ? 'var(--gold)' : 'var(--mute)';
const paths = {
site: ,
library: ,
folder: ,
file: ,
};
return ;
};
const spTabStyle = (active) => ({
height: 34, padding: '0 16px', borderRadius: '9px 9px 0 0', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
border: '1px solid var(--line)', borderBottom: active ? '1px solid var(--bg-card)' : '1px solid var(--line)',
background: active ? 'var(--bg-card)' : 'var(--bg-soft)',
color: active ? 'var(--maroon)' : 'var(--mute)',
position: 'relative', top: 1,
});
const spRow = {
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 14px',
borderBottom: '1px solid var(--line)', cursor: 'pointer',
};
const spName = { fontSize: 13, fontWeight: 600, color: 'var(--ink)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
const spSub = { fontSize: 11, color: 'var(--mute)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
const spCrumbBtn = { background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: 'var(--maroon)', padding: 0 };
const spEmpty = { padding: '48px 20px', textAlign: 'center', color: 'var(--mute)', fontSize: 13 };
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Lightweight entry modals β Text input and URL. Each collects raw content then
// hands off to the TaggingModal (confidentiality + tags) via onConfirm.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const spInputShell = (children, onClose) => (
{children}
);
const spFieldLabel = {
fontSize: 11, fontWeight: 700, color: 'var(--mute)',
letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 6, display: 'block',
};
const spTextField = {
width: '100%', height: 40, padding: '0 12px', borderRadius: 9,
border: '1px solid var(--line-2)', fontSize: 13, fontFamily: 'inherit',
outline: 'none', color: 'var(--ink)', background: 'white', boxSizing: 'border-box',
};
const TextInputModal = ({ open, onClose, onConfirm }) => {
const [title, setTitle] = React.useState('');
const [content, setContent] = React.useState('');
React.useEffect(() => { if (open) { setTitle(''); setContent(''); } }, [open]);
if (!open) return null;
const ready = content.trim().length > 0;
const submit = () => { if (ready) onConfirm({ title: title.trim() || 'Untitled note', content }); };
return spInputShell((
e.stopPropagation()} style={{
width: 560, maxWidth: '94%', background: 'var(--bg-card)', borderRadius: 18,
boxShadow: '0 30px 80px rgba(26,22,20,0.30), 0 0 0 1px var(--line)',
animation: 'rise 0.22s ease-out', display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
Add text
Paste notes or any text β itβs saved as a document you can tag.
), onClose);
};
const UrlInputModal = ({ open, onClose, onConfirm }) => {
const [url, setUrl] = React.useState('');
const [title, setTitle] = React.useState('');
React.useEffect(() => { if (open) { setUrl(''); setTitle(''); } }, [open]);
if (!open) return null;
const trimmed = url.trim();
const ready = /^https?:\/\/.+/i.test(trimmed);
const submit = () => { if (ready) onConfirm({ url: trimmed, title: title.trim() }); };
return spInputShell((
e.stopPropagation()} style={{
width: 560, maxWidth: '94%', background: 'var(--bg-card)', borderRadius: 18,
boxShadow: '0 30px 80px rgba(26,22,20,0.30), 0 0 0 1px var(--line)',
animation: 'rise 0.22s ease-out', display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
Add a URL
The link is saved as a reference for the research pipeline to fetch.
), onClose);
};
Object.assign(window, { TaggingModal, SharePointPickerModal, TextInputModal, UrlInputModal, SAMPLE_DROPPED, CONF_OPTIONS, SUGGESTED_TAGS });