/* global React, ReactDOM, TopNav, Footer, HomePage, BuildPage, GearLabPage, DataPage, BondingPage, PlatformsPage, SafetyPage, StreamerPage, ToolkitPage, DealsPage, PhoneFrame, MOBILE_VIEWS, TweaksPanel, useTweaks, TweakSection, TweakRadio, TweakSelect, TweakColor */
const { useState, useEffect } = React;
// PAGE_META: title + description + URL path per page. Drives both the
// deep-linking (URL ↔ React state) and the per-page
/ updates.
// The CF Pages _redirects file rewrites /* → /index.html 200 so deep links
// hit this SPA without 404ing at the edge.
const PAGE_META = {
home: { path: '/', title: 'livestreamers.org · the field guide & gear lab for streamers',
description: 'Tested home studios and IRL rigs, bonded uplinks, data-plan strategy, platform monetization, and creator safety — for streamers everywhere.' },
build: { path: '/build', title: 'Build Configurator · livestreamers.org',
description: 'Pick a lane (Phone-Vertical, Sleeper, or Prestige), swap any component, get a live BOM, runtime, weight, and data strategy. Every part lab-tested.' },
gear: { path: '/gear', title: 'Gear Lab · livestreamers.org',
description: '184 items scored on six axes — reliability, heat, battery impact, setup difficulty, price-to-value, and IRL suitability. Affiliates disclosed on every line.' },
data: { path: '/data', title: 'Data & Connectivity · livestreamers.org',
description: 'Real upload speeds, real deprioritization thresholds. 34 carriers tracked. Calyx Sprout sleeper. Quarterly re-tested.' },
bonding: { path: '/bonding', title: 'Cell Bonding · livestreamers.org',
description: 'LiveU Solo PRO vs Peplink BR1 vs BELABOX BEE vs Speedify — head-to-head TCO, latency, recovery. The most expensive decision in your IRL stack.' },
platforms: { path: '/platforms', title: 'Platforms · livestreamers.org',
description: 'Twitch, YouTube Live, TikTok Live, Kick, Facebook Live, X Live — monetization splits, bitrate caps, vertical support. Streams Charts Q1 2026 data.' },
safety: { path: '/safety', title: 'Safety & Legal · livestreamers.org',
description: 'Filming consent, permits, bystander privacy, harassment, theft, travel, insurance — independent, monthly. The beat no vendor blog will run.' },
streamer: { path: '/streamers', title: 'Streamer Profiles · livestreamers.org',
description: 'Real stacks, real economics, real failure logs. Steal-the-setup cards from the StreamerHouse living lab and 22 other profiles.' },
toolkit: { path: '/toolkit', title: 'Creator Toolkit · livestreamers.org',
description: 'Calculators, checklists, AI prompt packs — affiliate-free. Data usage, battery runtime, encoding profile, eligibility tracker, stream checklist.' },
deals: { path: '/deals', title: 'Deals & Buyer Guides · livestreamers.org',
description: 'Best-of buyer guides organised by use case. Weekly verified deals. No sponsored placement, ever.' },
};
// Reverse lookup helpers — kept tiny so the inline code in App stays clear.
const pathToPage = (path) => Object.keys(PAGE_META).find(k => PAGE_META[k].path === path);
const pageToPath = (key) => PAGE_META[key]?.path || '/';
const PAGES = {
home: { component: HomePage, title: 'Home' },
build: { component: BuildPage, title: 'Build Configurator' },
gear: { component: GearLabPage, title: 'Gear Lab' },
data: { component: DataPage, title: 'Data & Connectivity' },
bonding: { component: BondingPage, title: 'Cell Bonding' },
platforms: { component: PlatformsPage, title: 'Platforms' },
safety: { component: SafetyPage, title: 'Safety & Legal' },
streamer: { component: StreamerPage, title: 'Streamer Profile' },
toolkit: { component: ToolkitPage, title: 'Toolkit' },
deals: { component: DealsPage, title: 'Deals' },
};
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"viewport": "desktop",
"accent": "#b6ff3d"
}/*EDITMODE-END*/;
const App = () => {
// Initial page comes from the URL path so deep-linking works on first paint.
// typeof window guard keeps this safe in case anyone ever pre-renders this.
const initialPage = (typeof window !== 'undefined' && pathToPage(window.location.pathname)) || 'home';
const [page, setPageState] = useState(initialPage);
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// setPage wrapper: updates state AND pushes a history entry so the URL
// reflects the current page. Skip pushState if URL already matches.
const setPage = (key) => {
if (!PAGE_META[key]) return;
setPageState(key);
const target = pageToPath(key);
if (window.location.pathname !== target) {
window.history.pushState(null, '', target);
}
};
// popstate (back/forward) — sync React state to the new URL.
useEffect(() => {
const onPop = () => {
const k = pathToPage(window.location.pathname);
if (k) setPageState(k);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
// Per-page + .
// Crude but unblocks search/social previews until Phase 2's pre-render.
useEffect(() => {
const meta = PAGE_META[page];
if (!meta) return;
document.title = meta.title;
let descTag = document.querySelector('meta[name="description"]');
if (!descTag) {
descTag = document.createElement('meta');
descTag.setAttribute('name', 'description');
document.head.appendChild(descTag);
}
descTag.setAttribute('content', meta.description);
// Mirror the same to og: + twitter: tags so social previews update too.
['og:title', 'twitter:title'].forEach(prop => {
const sel = prop.startsWith('og:') ? `meta[property="${prop}"]` : `meta[name="${prop}"]`;
const el = document.querySelector(sel);
if (el) el.setAttribute('content', meta.title);
});
['og:description', 'twitter:description'].forEach(prop => {
const sel = prop.startsWith('og:') ? `meta[property="${prop}"]` : `meta[name="${prop}"]`;
const el = document.querySelector(sel);
if (el) el.setAttribute('content', meta.description);
});
const canonical = document.querySelector('link[rel="canonical"]');
if (canonical) canonical.setAttribute('href', 'https://livestreamers.org' + meta.path);
}, [page]);
// Push accent CSS var
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--accent', t.accent);
// derive related tokens
const hex = t.accent;
root.style.setProperty('--accent-faint', hex + '14');
root.style.setProperty('--accent-glow', hex + '38');
// accent ink (dark for bright, light for dim)
root.style.setProperty('--accent-ink', '#0a0a0c');
}, [t.accent]);
// Scroll to top on page change
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, [page]);
const PageComponent = PAGES[page].component;
const MobileView = MOBILE_VIEWS[page];
// Three viewport modes
if (t.viewport === 'mobile') {
return (
<>
>
);
}
if (t.viewport === 'paired') {
return (
<>
DESKTOP · {PAGES[page].title.toUpperCase()}
MOBILE COMPANION
{PAGES[page].title.toUpperCase()}
>
);
}
// Desktop default
return (
<>
>
);
};
const MobileShell = ({ page, setPage, MobileView }) => (
{Object.keys(PAGES).map(k => (
))}
);
const Tweaks = ({ t, setTweak }) => (
setTweak('viewport', v)}
options={[
{ value: 'desktop', label: 'Desktop' },
{ value: 'paired', label: 'Paired' },
{ value: 'mobile', label: 'Mobile' },
]} />
setTweak('accent', v)}
options={['#b6ff3d', '#ff2d6f', '#5ec8ff', '#9146ff']} />
);
ReactDOM.createRoot(document.getElementById('root')).render();