/* 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 (
{nav.map((it) => {
const on = active === it.id;
return (
go(it.id)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 12px', borderRadius: 'var(--radius-md)',
background: on ? 'var(--accent-soft)' : 'transparent', border: '1px solid', borderColor: on ? 'var(--border-accent)' : 'transparent',
cursor: 'pointer', textAlign: 'left', width: '100%',
fontFamily: 'var(--font-sans)', fontSize: 14.5, fontWeight: on ? 600 : 500,
color: on ? 'var(--text-strong)' : 'var(--text-muted)', transition: 'all var(--dur-fast) var(--ease-out)',
}}
onMouseEnter={(e) => { if (!on) { e.currentTarget.style.background = 'var(--surface-raised)'; e.currentTarget.style.color = 'var(--text-secondary)'; } }}
onMouseLeave={(e) => { if (!on) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; } }}>
{it.label}
{it.badge ? {it.badge} : null}
);
})}
{ e.currentTarget.style.color = 'var(--text-strong)'; e.currentTarget.style.background = 'var(--surface-raised)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-muted)'; e.currentTarget.style.background = 'var(--surface-card)'; }}>
Main website
{footer}
{onSignOut && (
{ e.currentTarget.style.background = 'var(--surface-raised)'; e.currentTarget.style.color = 'var(--text-strong)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; }}>
Sign out
)}
);
}
function UserCard({ name, sub, accent, src, onAvatar }) {
const ref = React.useRef(null);
return (
);
}
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 (
window.MSound && window.MSound.setEnabled(!on)} title={on ? 'Mute notification sounds' : 'Unmute notification sounds'}
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--surface-raised)', border: '1px solid var(--border-default)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: on ? 'var(--text-muted)' : 'var(--text-faint)' }}>
);
}
function Topbar({ title, sub, user, notifCount = 0, onBell, onMenu }) {
return (
{notifCount > 0 && {notifCount} }
{user && (
)}
);
}
/* 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}
))}
);
}
/* 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 (
setOpen(!open)} style={{ appearance: 'none', alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 7, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 600, color: count ? 'var(--accent-text)' : 'var(--text-muted)' }}>
{count ? count + (count === 1 ? ' note' : ' notes') : 'Add a note'}
{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 });