/* Mandera Portal — shared chrome (Icon, Sidebar, Topbar, helpers). Role-agnostic: client app and admin console both reuse these. */ const PASSET = '../assets/'; function initials(name) { return (name || '').split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?'; } function timeAgo(ts) { const s = Math.floor((Date.now() - ts) / 1000); if (s < 60) return 'just now'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; } /* Downscale an image file to a small square avatar dataUrl (fast to sync). */ function mReadAvatar(file, cb) { if (!file || !file.type || !file.type.startsWith('image')) { alert('Please choose an image file.'); return; } const r = new FileReader(); r.onload = () => { const img = new Image(); img.onload = () => { const S = 256; const c = document.createElement('canvas'); c.width = S; c.height = S; const x = c.getContext('2d'); const side = Math.min(img.width, img.height); x.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, S, S); cb(c.toDataURL('image/jpeg', 0.85)); }; img.onerror = () => alert('Could not read that image.'); img.src = r.result; }; r.readAsDataURL(file); } /* Upload a file to the server store (api/portal-files.php → /portal-uploads/). Resolves to {url,name,type,size} or null when the server isn't reachable. */ async function mUploadFile(file) { try { const fd = new FormData(); fd.append('file', file); const res = await fetch('../api/portal-files.php', { method: 'POST', body: fd }); const j = await res.json(); if (j && j.ok && j.url) return { url: j.url, name: file.name, type: file.type || '', size: file.size }; } catch (e) {} return null; } /* Small-file fallback: read as dataUrl if under maxMB, else null. */ function mReadSmall(file, maxMB) { return new Promise((resolve) => { if (file.size > (maxMB || 2.5) * 1048576) return resolve(null); const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => resolve(null); r.readAsDataURL(file); }); } function mAvatarOf(accountId) { if (!accountId || !window.MData) return null; const a = window.MData.get('accounts').find((x) => x.id === accountId); return (a && a.avatar) || null; } function Avatar({ name, src, size = 38, accent, fontSize, style }) { const fs = fontSize || Math.round(size * 0.36); const base = { width: size, height: size, borderRadius: '50%', flex: 'none', overflow: 'hidden', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', ...style }; if (src) return ; return ( {initials(name)} ); } function PIcon({ n, s = 18, color = 'currentColor', stroke = 1.8 }) { const ref = React.useRef(null); React.useEffect(() => { if (ref.current && window.lucide) { ref.current.innerHTML = ''; const el = document.createElement('i'); el.setAttribute('data-l', n); ref.current.appendChild(el); window.lucide.createIcons({ nameAttr: 'data-l', attrs: { width: s, height: s, stroke: color, 'stroke-width': stroke }, root: ref.current }); } }, [n, s, color, stroke]); return ; } function PLogo({ height = 22, label = 'Client Portal' }) { return (
Mandera {label}
); } function Sidebar({ nav, active, go, label, footer, onSignOut, mobileOpen }) { return ( ); } function UserCard({ name, sub, accent, src, onAvatar }) { const ref = React.useRef(null); return (
{onAvatar && { const f = e.target.files && e.target.files[0]; if (f) mReadAvatar(f, onAvatar); e.target.value = ''; }} />}
{name}
{sub}
); } function SoundToggle() { const [on, setOn] = React.useState(window.MSound ? window.MSound.isEnabled() : true); React.useEffect(() => { const h = () => setOn(window.MSound ? window.MSound.isEnabled() : true); window.addEventListener('msound-changed', h); return () => window.removeEventListener('msound-changed', h); }, []); return ( ); } function Topbar({ title, sub, user, notifCount = 0, onBell, onMenu }) { return (

{title}

{sub &&
{sub}
}
{user && (
{user.name}
{user.sub}
)}
); } /* Comment / note thread stored on any record (item.notes = [{who,role,text,ts}]). Used on content, timeline and more — clients and the team talk about a specific item and the notes appear right under it on BOTH sides. */ function PNotes({ coll, item, me, clientId, what, link, onChange, placeholder }) { const notes = Array.isArray(item.notes) ? item.notes : []; const [text, setText] = React.useState(''); const isTeam = me.role !== 'client'; const submit = (e) => { e.preventDefault(); const t = text.trim(); if (!t) return; const note = { id: 'n' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), who: me.name || (isTeam ? 'Mandera' : 'Client'), role: isTeam ? 'team' : 'client', text: t, ts: Date.now() }; window.MData.update(coll, item.id, { notes: notes.concat([note]) }); const cl = clientId ? window.MData.client(clientId) : null; const label = what || '“' + (item.title || item.task || 'an item') + '”'; if (isTeam) window.MData.notify({ clientId, type: 'note', text: 'Mandera left a note on ' + label + ': “' + t + '”', link: link || 'calendar', refId: item.id }); else window.MData.notify({ audience: 'team', type: 'note', text: (cl ? cl.brand : 'A client') + ' commented on ' + label + ': “' + t + '”', link: 'clients', refId: clientId }); setText(''); onChange && onChange(); }; return (
{notes.map((n) => (
{n.who}{n.role === 'team' ? ' · Mandera' : ''} · {timeAgo(n.ts)}
{n.text}
))}
setText(e.target.value)} placeholder={placeholder || 'Write a note…'} style={{ flex: 1, minWidth: 0, height: 36, padding: '0 12px', background: 'var(--surface-sunken)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', color: 'var(--text-strong)', fontFamily: 'var(--font-sans)', fontSize: 13.5, outline: 'none' }} onFocus={(e) => e.target.style.borderColor = 'var(--accent)'} onBlur={(e) => e.target.style.borderColor = 'var(--border-default)'} />
); } /* Collapsible PNotes — shows a small “N notes” toggle; auto-open when there already are notes so they're visible without a click. */ function PNotesBox(props) { const count = Array.isArray(props.item.notes) ? props.item.notes.length : 0; const [open, setOpen] = React.useState(count > 0); return (
{open && }
); } function MarkWash({ opacity = 0.1, size = 560, pos = { top: '-160px', right: '-160px' } }) { return ; } /* Fixed warning bar shown ONLY when the shared database can't be reached — so the owner immediately knows accounts/messages won't sync between devices (the #1 cause of "the client can't sign in"). Silent when all is well. */ function ConnBanner() { const [state, setState] = React.useState('checking'); // checking | ok | offline React.useEffect(() => { let alive = true; const api = (window.MData && window.MData.API) || '../api/portal-api.php'; fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'health' }) }) .then((r) => r.json()).then((j) => { if (alive) setState(j && j.ok ? 'ok' : 'offline'); }) .catch(() => { if (alive) setState('offline'); }); return () => { alive = false; }; }, []); if (state !== 'offline') return null; return (
⚠ Not connected to the shared database — new accounts & messages won’t sync between devices. Open /api/health.php on your site to fix it.
); } Object.assign(window, { PIcon, PLogo, Sidebar, Topbar, UserCard, MarkWash, Avatar, ConnBanner, SoundToggle, PNotes, PNotesBox, mReadAvatar, mUploadFile, mReadSmall, mAvatarOf, PASSET, mInitials: initials, mTimeAgo: timeAgo });