// Shared dashboard primitives — used across guest/host/admin sections const D = window.TLB_DASH_DATA; const CC = window.TLB_TOKENS.color; const dashPad = () => { const d = (window.TLB_TWEAKS && window.TLB_TWEAKS.density) || 'comfortable'; return d === 'compact' ? 16 : d === 'spacious' ? 32 : 24; }; const dashGap = () => { const d = (window.TLB_TWEAKS && window.TLB_TWEAKS.density) || 'comfortable'; return d === 'compact' ? 16 : d === 'spacious' ? 32 : 24; }; // ── tlbConfirm — dependency-free in-app confirmation ────────────────── // v141.189 — Destructive dashboard actions (Sign out, Delete villa, …) // gated on native window.confirm(). Chrome SILENTLY returns false from // confirm() once the user ticks "prevent this page from creating more // dialogs", and in embedded/preview/iframe contexts — so those handlers // returned on their first line and the buttons looked dead ("tidak // merespon") while non-confirm buttons kept working. This builds its own // DOM overlay (no native dialog, unaffected by that browser setting or // iframes) and resolves a Promise. Shared by every dashboard // shell because this module loads before the section scripts. window.tlbConfirm = function tlbConfirm(message, opts) { opts = opts || {}; const confirmText = opts.confirmText || 'Confirm'; const cancelText = opts.cancelText || 'Cancel'; const danger = !!opts.danger; const accent = danger ? '#9a1c1c' : '#1f6b3a'; return new Promise((resolve) => { let done = false; const finish = (val) => { if (done) return; done = true; document.removeEventListener('keydown', onKey, true); try { ov.remove(); } catch (_) {} resolve(val); }; // Fallback if the DOM isn't usable for some reason. if (!document || !document.body) { let r = true; try { r = window.confirm(message); } catch (_) { r = true; } return resolve(r); } const ov = document.createElement('div'); ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.style.cssText = 'position:fixed;inset:0;z-index:2147483600;display:flex;' + 'align-items:center;justify-content:center;background:rgba(20,20,20,0.45);' + 'backdrop-filter:blur(2px);padding:24px;font-family:ui-sans-serif,system-ui,sans-serif'; const card = document.createElement('div'); card.style.cssText = 'background:#fff;color:#1a1a1a;max-width:420px;width:100%;' + 'border-radius:14px;padding:24px;box-shadow:0 24px 60px rgba(0,0,0,0.28);' + 'border:1px solid rgba(0,0,0,0.08)'; const msg = document.createElement('div'); msg.textContent = String(message == null ? 'Are you sure?' : message); msg.style.cssText = 'font-size:14px;line-height:1.5;margin-bottom:20px;white-space:pre-wrap'; const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:10px;justify-content:flex-end'; const mkBtn = (label, primary) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.style.cssText = 'padding:9px 18px;border-radius:8px;font-size:12.5px;' + 'font-weight:600;cursor:pointer;letter-spacing:0.02em;' + (primary ? 'border:1px solid ' + accent + ';background:' + accent + ';color:#fff' : 'border:1px solid rgba(0,0,0,0.18);background:#fff;color:#444'); return b; }; const cancelB = mkBtn(cancelText, false); const okB = mkBtn(confirmText, true); cancelB.addEventListener('click', () => finish(false)); okB.addEventListener('click', () => finish(true)); ov.addEventListener('click', (e) => { if (e.target === ov) finish(false); }); const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); finish(false); } else if (e.key === 'Enter') { e.preventDefault(); finish(true); } }; document.addEventListener('keydown', onKey, true); row.appendChild(cancelB); row.appendChild(okB); card.appendChild(msg); card.appendChild(row); ov.appendChild(card); document.body.appendChild(ov); try { okB.focus(); } catch (_) {} }); }; // ── Live-data status pill (reads from window.TLB_LIVE_STATUS) ──── function LivePill({ which }) { const [, setTick] = React.useState(0); React.useEffect(() => { const h = () => setTick(t => t + 1); window.addEventListener('tlb-live-loaded', h); // Re-render once after 1.5s in case the event fired before mount const to = setTimeout(h, 1500); return () => { window.removeEventListener('tlb-live-loaded', h); clearTimeout(to); }; }, []); const s = (window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS[which]) || { live: false, sources: [], counts: {} }; const counts = s.counts || {}; const summary = Object.keys(counts).length ? Object.entries(counts).map(([k, v]) => v + ' ' + k).join(' · ') : (s.live ? 'real data' : 'loading…'); return ( {s.live ? 'LIVE · ' + summary : 'DEMO · mock data'} ); } // ── Editorial section header (used in every dashboard view) ───── function DashHeader({ eyebrow, title, italic, sub, right, liveFor }) { return (
{eyebrow} {liveFor && }
{title} {sub && {sub}}
{right}
); } // ── DashTopbar — v141.167 re-skin Stage 5 ────────────────────── // Global sticky workspace bar from the delivered redesign Topbar: // brand lockup left, calm "Concierge online" dotglow + live Bali time // + profile glyph right. Deliberately self-contained (no data/API // deps) so mounting it in any role shell is zero-regression-risk. function DashTopbar({ role = 'Workspace' }) { const [t, setT] = React.useState(''); React.useEffect(() => { var fmt = function () { try { return new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Makassar' }); } catch (e) { return ''; } }; setT(fmt()); var id = setInterval(function () { setT(fmt()); }, 30000); return function () { clearInterval(id); }; }, []); return (
The Luxury Bali {role}
Concierge online · Bali {t}
); } // ── Card — strict white panel, 18px radius ───────────────────── // v141.124 (Phase 1 redesign) — added subtle two-layer shadow and an // optional `accent` prop that places a 1px gold hairline along the top // edge. Both upgrades are additive: existing callers see a marginally // softer panel; opting into `accent` lights up the editorial accent. // All existing prop names + style overrides preserved. function Panel({ children, style = {}, pad, accent = false }) { const p = pad ?? dashPad(); return (
{accent && (
)} {children}
); } // ── KPI tile ──────────────────────────────────────────────────── // v141.124 (Phase 1 redesign) — value bumped 36 → 48px to match the // new master design ("Make the data the hero" per the v141.123 brief // §7.2 #2). Added tabular-nums so 7-digit IDR amounts align across the // KPI grid. All existing props unchanged. // v141.137 — SectionErrorBoundary. Catches React errors thrown by any // section component and renders an actionable fallback instead of a // blank main area. Logged to console.error so devtools still surfaces // the stack trace for debugging. Reset via section change (parent // re-keys this boundary using the section key so navigating to another // section gives the next render a fresh try). class SectionErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, info) { console.error('[SectionErrorBoundary] render failed:', error, info); } render() { if (this.state.hasError) { const errMsg = (this.state.error && this.state.error.message) || 'Unknown error'; return (
SECTION ERROR This section hit a snag. The page tried to load and a JavaScript error stopped it before render. Other sections still work — switch sections in the sidebar to keep going.
{errMsg.slice(0, 240)}
); } return this.props.children; } } window.SectionErrorBoundary = SectionErrorBoundary; function KPI({ label, value, delta, alert }) { return ( {label} {value} {delta && {delta}} ); } // ── Channel badge ────────────────────────────────────────────── function ChannelBadge({ channel, size = 11 }) { const map = { whatsapp: { icon: '◐', label: 'WhatsApp', bg: '#f5f5f5' }, email: { icon: '◊', label: 'Email', bg: '#f5f5f5' }, 'in-app': { icon: '◇', label: 'In-app', bg: '#f5f5f5' }, web: { icon: '◯', label: 'Web', bg: '#f5f5f5' }, push: { icon: '◉', label: 'Push', bg: '#f5f5f5' }, system: { icon: '·', label: 'System', bg: '#f5f5f5' }, alert: { icon: '!', label: 'Alert', bg: '#1a1a1a', dark: true }, }; const c = map[channel] || map.system; return ( {c.icon}{c.label} ); } // ── Aetheria 4×4 dot marker (used on cross-brand items) ─────── function AetheriaDot({ inline = false }) { return ( ); } // ── SLA pill (countdown) ─────────────────────────────────────── function SLAPill({ mins, urgent }) { const danger = urgent || mins < 5; const warn = !danger && mins < 15; const bg = danger ? '#9a1c1c' : warn ? '#1a1a1a' : CC.chip; const fg = (danger || warn) ? CC.white : CC.deep; return ( {danger ? '● ' : ''}{mins}m left ); } // ── Section sub-tab (pill row) ───────────────────────────────── function SubTabs({ tabs, active, onChange }) { return (
{tabs.map(t => { const on = active === t.key; return ( ); })}
); } // ── Image placeholder (subtle stripe + monospace caption) ────── function ImgFrame({ url, ratio = '4/5', tag, num, dark }) { return (
{!url && (
)} {url &&
} {tag && {tag}} {num && {num}}
); } // ── Per-panel data-source badge ─────────────────────────────── // Drop into any panel to label whether its data is live or mock. // // Renders: // ● LIVE · 152 villas · 47 bookings (any source matched) // ○ DEMO · sample data (none of the needs loaded) // `needs` accepts the keys used in TLB_LIVE_STATUS[*].counts: // villas, offers, bookings, enquiries, wishlist, disputes, // loyalty, vouchers, reviews, payouts, health, admin/pulse function DataBadge({ dash, needs = [], compact = false }) { const [, setTick] = React.useState(0); React.useEffect(() => { const h = () => setTick(t => t + 1); window.addEventListener('tlb-live-loaded', h); const to = setTimeout(h, 1500); return () => { window.removeEventListener('tlb-live-loaded', h); clearTimeout(to); }; }, []); const s = (window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS[dash]) || { sources: [], counts: {} }; const matched = needs.filter(n => s.sources.includes(n) || (s.counts && s.counts[n] != null)); const live = matched.length > 0; const detail = matched.length ? matched.map(n => (s.counts[n] != null ? s.counts[n] + ' ' + n : n)).join(' · ') : 'sample data'; return ( '/api/' + n).join(', ') : 'No live source — preview only'} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: compact ? '2px 8px' : '4px 10px', borderRadius: 100, background: live ? '#e7f1ec' : CC.chip, border: '1px solid ' + (live ? '#9bc8b1' : CC.border), fontFamily: F_TLB.ui, fontSize: compact ? 9 : 10, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: live ? '#2f7a5a' : CC.mid, whiteSpace: 'nowrap', }}> {live ? 'Live · ' + detail : 'Demo'} ); } // ── Dash icons — minimal line glyphs (24×24, stroke 1.5) ────────── // One source for every sidebar entry. Lightweight inline SVG so // they inherit currentColor and animate cleanly with the sidebar. const DashIcons = { // Generic / utility grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z', activity: 'M22 12h-4l-3 9L9 3l-3 9H2', alert: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01', users: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75', building: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-4M9 9v.01M9 12v.01M9 15v.01M9 18v.01', checkCircle: 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3', list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01', layout: 'M3 3h18v18H3zM3 9h18M9 21V9', server: 'M2 4h20v6H2zM2 14h20v6H2zM6 7h.01M6 17h.01', dollar: 'M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6', link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.72', fileText: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8', sparkles: 'M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1', barChart: 'M12 20V10M18 20V4M6 20v-6', target: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12zM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z', share: 'M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98', cpu: 'M4 4h16v16H4zM9 9h6v6H9zM9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3', globe: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z', palette: 'M12 22A10 10 0 0 1 12 2c5.523 0 10 4.477 10 10 0 1.657-1.343 3-3 3h-3a2 2 0 0 0-2 2c0 1.105-.895 2-2 2zM6.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM10.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM15.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM17.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z', trending: 'M23 6l-9.5 9.5-5-5L1 18M17 6h6v6', message: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z', wand: 'M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8l1.4 1.4M15 9h.01M17.8 6.2l1.4-1.4M3 21l9-9M12.2 6.2l-1.4-1.4', // Host home: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM9 22V12h6v10', inbox: 'M22 12h-6l-2 3h-4l-2-3H2M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z', calendar: 'M19 4H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM16 2v4M8 2v4M3 10h18', messageSq: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z', calCheck: 'M19 4H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM16 2v4M8 2v4M3 10h18M9 16l2 2 4-4', star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z', tag: 'M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01', checkSquare: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11', phone: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z', search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35', bookOpen: 'M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z', settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z', // Guest map: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4zM8 2v16M16 6v16', heart: 'M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z', award: 'M12 15a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM8.21 13.89 7 23l5-3 5 3-1.21-9.12', gift: 'M20 12v10H4V12M2 7h20v5H2zM12 22V7M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z', shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', compass: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM16.24 7.76l-2.12 6.36-6.36 2.12 2.12-6.36 6.36-2.12z', }; // Map sidebar item icon names (string) to SVG path. function DashIcon({ name, size = 18 }) { const path = DashIcons[name] || DashIcons.grid; return ( ); } // ── DashSidebar — modern collapsible navigation rail ────────────── // // Editorial-design sidebar with two states: // expanded (260px) — full labels + group headers + brand block // collapsed (72px) — icons only, group headers shrink to 1px line, // labels surface as hover tooltips // // State persists in localStorage('tlb-sidebar-collapsed') so it // survives reloads. Smooth 220ms cubic-bezier transition between // states. Active item: 3px left accent + subtle bg. Hover: very // gentle bg tint. All chrome respects the editorial palette. // // Each item = { key, label, group?, icon?, mode? } // icon: name from DashIcons (e.g. 'home', 'users', 'star') // mode: 'native' (React tab) or 'iframe' (operational page embed) function DashSidebar({ items, active, onChange, brand = 'TLB', sub = 'Master', dark = true, footer }) { const STORAGE_KEY = 'tlb-sidebar-collapsed'; const [collapsed, setCollapsed] = React.useState(() => { try { return localStorage.getItem(STORAGE_KEY) === '1'; } catch { return false; } }); React.useEffect(() => { try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch {} }, [collapsed]); // Group items const groups = {}; items.forEach(it => { const g = it.group || 'Main'; if (!groups[g]) groups[g] = []; groups[g].push(it); }); const W_OPEN = 260; const W_COLLAPSED = 72; const width = collapsed ? W_COLLAPSED : W_OPEN; // v141.164 re-skin Stage 3 — light nav rail to the delivered redesign // "white" theme: calm subtle-tint selected state + left accent bar // (NOT a heavy black pill), hairline border. Dark-mode branch is // unchanged. Nav logic/props/structure untouched. const fg = dark ? '#f0f0f0' : '#141414'; const fgMuted = dark ? 'rgba(240,240,240,0.55)' : 'rgba(20,20,20,0.55)'; const fgFaint = dark ? 'rgba(240,240,240,0.4)' : 'rgba(20,20,20,0.4)'; const bg = dark ? '#0a0a0a' : '#ffffff'; const bgHover = dark ? 'rgba(255,255,255,0.05)' : 'rgba(20,20,20,0.04)'; const bgActive = dark ? 'rgba(255,255,255,0.12)' : 'rgba(20,20,20,0.06)'; const fgActive = dark ? '#ffffff' : CC.ink; const border = dark ? 'rgba(255,255,255,0.06)' : CC.hairline; return ( ); } // ── IframeView — embed an operational page inside the new dashboard // Used for sections we haven't built native React views for yet. // The operational page detects ?embed=1 (added in admin.html / host.html // / account.html) and hides its own sidebar/topbar, so it slots in // cleanly as the right-hand panel. function IframeView({ src, title }) { const [height, setHeight] = React.useState(800); const ref = React.useRef(null); // v141.70 — Carry deep-link params from the parent URL through to the // embedded classic page. Cards in the Site Manager grid link to URLs // like `?section=sitemanager&page=studio` — that click hits the // admin-classic auto-redirect which converts it to `?section=...`, // the new admin shell mounts the iframe, and we want the iframe URL // to be `/admin-classic?tab=sitemanager&page=studio&embed=1` so the // classic page lands on the right sub-tab. The pass-through params // are: `page`, `panel`, `id`, `property`, `action`. Anything not on // the whitelist is ignored (no surprise leakage). const fullSrc = React.useMemo(() => { try { var parentParams = new URLSearchParams(window.location.search); var passThrough = ['page', 'panel', 'id', 'property', 'action']; var u = new URL(src, window.location.origin); passThrough.forEach(function(k) { var v = parentParams.get(k); if (v != null && !u.searchParams.has(k)) u.searchParams.set(k, v); }); return u.pathname + (u.search || ''); } catch { return src; } }, [src]); React.useEffect(() => { // Try to auto-size the iframe to its content if same-origin const tryResize = () => { try { const doc = ref.current?.contentWindow?.document; if (doc && doc.body) { const h = Math.max(800, doc.body.scrollHeight + 32); setHeight(h); } } catch {} }; const t = setTimeout(tryResize, 1500); const t2 = setInterval(tryResize, 4000); return () => { clearTimeout(t); clearInterval(t2); }; }, [fullSrc]); return (
OPERATIONAL VIEW · LIVE DATA
{/* v141.70 — "Open in full window" removed. Classic pages now auto-redirect direct visits back to /dashboards/admin or /dashboards/host, so this button would loop. */}