/* 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 /<meta> 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 <title> + <meta name="description">. // 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 ( <> <MobileShell page={page} setPage={setPage} MobileView={MobileView} /> <Tweaks t={t} setTweak={setTweak} /> </> ); } if (t.viewport === 'paired') { return ( <> <div className="app"> <TopNav page={page} onNav={setPage} /> <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 440px', gap: 32, padding: '24px 32px', alignItems: 'start', }}> <div style={{ border: '1px solid var(--line)', borderRadius: 12, overflow: 'hidden', background: 'var(--bg-0)', minHeight: 600, }}> <div style={{ padding: '10px 14px', background: 'var(--bg-1)', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', }}> <div className="row gap-2"> <span style={{ width: 10, height: 10, borderRadius: '50%', background: '#ff5d6c' }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: '#ffc857' }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: '#b6ff3d' }} /> </div> <span className="mono fs-12 text-fg4">DESKTOP · {PAGES[page].title.toUpperCase()}</span> <span></span> </div> <div style={{ transform: 'scale(0.62)', transformOrigin: 'top left', width: '161.3%' }}> <PageComponent onNav={setPage} /> </div> </div> <div style={{ position: 'sticky', top: 100 }}> <div className="mb-3 row between"> <div className="eyebrow"><span className="dot" />MOBILE COMPANION</div> <span className="mono fs-12 text-fg4">{PAGES[page].title.toUpperCase()}</span> </div> <PhoneFrame page={page}> <MobileView onNav={setPage} /> </PhoneFrame> </div> </div> </div> <Tweaks t={t} setTweak={setTweak} /> </> ); } // Desktop default return ( <> <div className="app"> <TopNav page={page} onNav={setPage} /> <main> <PageComponent onNav={setPage} /> </main> <Footer onNav={setPage} /> </div> <Tweaks t={t} setTweak={setTweak} /> </> ); }; const MobileShell = ({ page, setPage, MobileView }) => ( <div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24, background: 'radial-gradient(800px 500px at 50% 0%, rgba(182,255,61,0.04), transparent 60%), var(--bg-0)', }}> <div className="col gap-4" style={{ alignItems: 'center' }}> <div className="row gap-2"> {Object.keys(PAGES).map(k => ( <button key={k} onClick={() => setPage(k)} className={`pill ${page === k ? 'is-on' : ''}`}> {PAGES[k].title} </button> ))} </div> <PhoneFrame page={page}> <MobileView onNav={setPage} /> </PhoneFrame> </div> </div> ); const Tweaks = ({ t, setTweak }) => ( <TweaksPanel title="Tweaks"> <TweakSection title="Viewport mode"> <TweakRadio value={t.viewport} onChange={(v) => setTweak('viewport', v)} options={[ { value: 'desktop', label: 'Desktop' }, { value: 'paired', label: 'Paired' }, { value: 'mobile', label: 'Mobile' }, ]} /> </TweakSection> <TweakSection title="Accent"> <TweakColor value={t.accent} onChange={(v) => setTweak('accent', v)} options={['#b6ff3d', '#ff2d6f', '#5ec8ff', '#9146ff']} /> </TweakSection> </TweaksPanel> ); ReactDOM.createRoot(document.getElementById('root')).render(<App />);