// 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}>
{d.name}
β€Ί
))} {/* 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}>
{it.name}
β€Ί
); } return (
toggleFile(it)} style={{ ...spRow, background: checked ? 'var(--maroon-06)' : 'transparent' }}>
{checked && }
{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 {paths[kind]}; }; 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.
setTitle(e.target.value)} placeholder="e.g. CFO interview notes" style={spTextField} />