feat(world): react entry — unified shell, globe island, variant tabs, markets panel
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hanzo World — React + GUI (foundation)</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Hanzo World — React 19 + @hanzo/gui foundation preview. The shipping surface remains the vanilla app at index.html."
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favico/hanzo-favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="react-root"></div>
|
||||
<script type="module" src="/src/react/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { YStack, XStack } from '@hanzo/gui';
|
||||
import { HanzoAppHeader } from '@hanzogui/shell';
|
||||
import { getSiteVariant, setSiteVariantRuntime } from '@/config/variant';
|
||||
import { GlobeIsland } from './components/GlobeIsland';
|
||||
import { VariantTabs } from './components/VariantTabs';
|
||||
import { MarketsPanel } from './components/MarketsPanel';
|
||||
import { AccountControl } from './components/AccountControl';
|
||||
|
||||
/**
|
||||
* The React + @hanzo/gui foundation for world.hanzo.ai.
|
||||
*
|
||||
* Architecture proven end-to-end here:
|
||||
* 1. Unified signed-in shell — HanzoAppHeader(productId="world") from
|
||||
* @hanzogui/shell, themed by @hanzo/brand tokens (monochrome, accent #fff).
|
||||
* 2. The deck.gl globe as a React island (GlobeIsland) wrapping the EXISTING
|
||||
* MapContainer — not a rewrite.
|
||||
* 3. Variant tabs + one real ported panel (MarketsPanel, reusing the live
|
||||
* markets service) as proof the panel pattern moves cleanly onto @hanzo/gui.
|
||||
*/
|
||||
export function App(): React.JSX.Element {
|
||||
const [variant, setVariant] = useState<string>(() => getSiteVariant());
|
||||
|
||||
// One switch path: canonicalize + persist through the config layer, then reflect
|
||||
// it in React state and the shareable URL. Mirrors the vanilla in-place switch.
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
const applied = setSiteVariantRuntime(id);
|
||||
if (!applied) return;
|
||||
setVariant(applied);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('variant', applied);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<YStack flex={1} height="100%" backgroundColor="#000">
|
||||
<HanzoAppHeader
|
||||
productId="world"
|
||||
org={{ id: 'hanzo', label: 'Hanzo' }}
|
||||
search={{ placeholder: 'Search or ask Hanzo…' }}
|
||||
account={<AccountControl />}
|
||||
/>
|
||||
|
||||
<XStack px="$3" py="$2" ai="center" jc="flex-start" gap="$3" zIndex={10}>
|
||||
<VariantTabs active={variant} onSelect={handleSelect} />
|
||||
</XStack>
|
||||
|
||||
{/* Stage: the globe fills the viewport; panels float over it. */}
|
||||
<YStack flex={1} position="relative" overflow="hidden">
|
||||
<GlobeIsland variant={variant} />
|
||||
<YStack position="absolute" top="$3" right="$3" gap="$3" zIndex={20}>
|
||||
<MarketsPanel />
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { XStack, SizableText } from '@hanzo/gui';
|
||||
|
||||
/**
|
||||
* AccountControl — the far-right account affordance handed to HanzoAppHeader's
|
||||
* `account` slot. A minimal monogram for the foundation slice; the real IAM-bound
|
||||
* avatar (shell `UserAvatar` / `useTenantAuth` against hanzo.id) lands in a later
|
||||
* round when auth is wired into the React surface.
|
||||
*/
|
||||
export function AccountControl(): React.JSX.Element {
|
||||
return (
|
||||
<XStack
|
||||
tag="button"
|
||||
focusable
|
||||
cursor="pointer"
|
||||
width={32}
|
||||
height={32}
|
||||
borderRadius={999}
|
||||
ai="center"
|
||||
jc="center"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.2)"
|
||||
backgroundColor="rgba(255,255,255,0.06)"
|
||||
hoverStyle={{ backgroundColor: 'rgba(255,255,255,0.12)' }}
|
||||
aria-label="Account"
|
||||
>
|
||||
<SizableText size="$2" color="$color12">
|
||||
H
|
||||
</SizableText>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { variantConfig } from '@/config';
|
||||
import type { MapContainer as MapContainerType } from '@/components/MapContainer';
|
||||
|
||||
/**
|
||||
* GlobeIsland — the deck.gl globe as a React island.
|
||||
*
|
||||
* This does NOT rewrite the globe. It owns a plain DOM host <div> and, on mount,
|
||||
* instantiates the EXISTING `MapContainer` (which delegates to DeckGLMap on
|
||||
* desktop / the D3-SVG fallback on mobile, exactly as the vanilla app does). React
|
||||
* owns the lifecycle (mount / variant-driven layer swap / unmount); the globe
|
||||
* engine stays imperative and untouched behind that host node. With no mapbox
|
||||
* token locally it renders the dotted-fallback "cybermap" globe — expected.
|
||||
*
|
||||
* The heavy map chunk (~2.7 MB mapbox-gl + deck.gl) is dynamically imported, so
|
||||
* it never blocks first paint — mirroring the vanilla `App.mountMap()`.
|
||||
*/
|
||||
export function GlobeIsland({ variant }: { variant: string }): React.JSX.Element {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MapContainerType | null>(null);
|
||||
|
||||
// Mount the globe once. React StrictMode double-invokes effects in dev; the
|
||||
// `cancelled` guard + teardown makes the instantiate/destroy pair idempotent so
|
||||
// we never leak two WebGL contexts.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
void (async () => {
|
||||
const { MapContainer } = await import('@/components/MapContainer');
|
||||
if (cancelled || mapRef.current) return;
|
||||
mapRef.current = new MapContainer(host, {
|
||||
zoom: 1.35,
|
||||
pan: { x: 0, y: 0 },
|
||||
view: 'global',
|
||||
// Reuse the canonical per-variant layer defaults — one source of truth in
|
||||
// the config layer, no hand-rolled layer object here.
|
||||
layers: variantConfig(variant).DEFAULT_MAP_LAYERS,
|
||||
timeRange: '7d',
|
||||
mode: '3d',
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mapRef.current?.destroy();
|
||||
mapRef.current = null;
|
||||
};
|
||||
// Mount-only: the variant-driven layer swap is handled by the effect below so
|
||||
// switching a tab never tears down / cold-starts the WebGL context.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Variant change → swap layers in place (keep-alive), the same decomplected
|
||||
// `setLayers` path the vanilla in-place switch uses.
|
||||
useEffect(() => {
|
||||
mapRef.current?.setLayers(variantConfig(variant).DEFAULT_MAP_LAYERS);
|
||||
}, [variant]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
id="mapContainer"
|
||||
aria-label="Interactive world globe"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { YStack, XStack, SizableText } from '@hanzo/gui';
|
||||
import { fetchMultipleStocks } from '@/services/markets';
|
||||
import { MARKET_SYMBOLS } from '@/config';
|
||||
import { formatPrice, formatChange } from '@/utils';
|
||||
import type { MarketData } from '@/types';
|
||||
import { PanelCard } from './PanelCard';
|
||||
|
||||
/**
|
||||
* MarketsPanel — the vanilla `MarketPanel` (markets) ported to React as proof the
|
||||
* panel pattern moves cleanly onto @hanzo/gui.
|
||||
*
|
||||
* It REUSES the existing data + formatting layer verbatim — `fetchMultipleStocks`
|
||||
* (the same Finnhub/Yahoo service the vanilla panel is fed by, streaming partial
|
||||
* batches via `onBatch`) and `formatPrice` / `formatChange` — and only re-expresses
|
||||
* the row markup with Tamagui primitives. No data logic is rewritten; the port is
|
||||
* purely the view.
|
||||
*/
|
||||
export function MarketsPanel(): React.JSX.Element {
|
||||
const [rows, setRows] = useState<MarketData[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await fetchMultipleStocks(MARKET_SYMBOLS, {
|
||||
onBatch: (partial) => {
|
||||
if (!cancelled) setRows([...partial]);
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
setRows(result.data);
|
||||
if (result.data.length === 0) {
|
||||
setError(
|
||||
result.skipped
|
||||
? 'FINNHUB_API_KEY not configured — add in Settings'
|
||||
: 'Market data unavailable',
|
||||
);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError('Market data unavailable');
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
// Live surface: refresh on the same cadence spirit as the vanilla poller.
|
||||
const id = window.setInterval(() => void load(), 60_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PanelCard title="Markets">
|
||||
{error && rows.length === 0 ? (
|
||||
<SizableText size="$2" color="$color9">
|
||||
{error}
|
||||
</SizableText>
|
||||
) : rows.length === 0 ? (
|
||||
<SizableText size="$2" color="$color9">
|
||||
Loading…
|
||||
</SizableText>
|
||||
) : (
|
||||
<YStack gap="$1.5">
|
||||
{rows.map((stock) => (
|
||||
<MarketRow key={stock.symbol} stock={stock} />
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketRow({ stock }: { stock: MarketData }): React.JSX.Element {
|
||||
const change = stock.change ?? 0;
|
||||
const changeColor = change >= 0 ? '#22c55e' : '#ef4444';
|
||||
return (
|
||||
<XStack jc="space-between" ai="center" py="$1">
|
||||
<YStack>
|
||||
<SizableText size="$3" color="$color12">
|
||||
{stock.name}
|
||||
</SizableText>
|
||||
<SizableText size="$1" color="$color9">
|
||||
{stock.display}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
<XStack gap="$3" ai="center">
|
||||
<SizableText size="$3" color="$color12">
|
||||
{stock.price != null ? formatPrice(stock.price) : '—'}
|
||||
</SizableText>
|
||||
<SizableText size="$2" color={changeColor} style={{ minWidth: 64, textAlign: 'right' }}>
|
||||
{stock.change != null ? formatChange(stock.change) : '—'}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { YStack, XStack, SizableText } from '@hanzo/gui';
|
||||
|
||||
/**
|
||||
* PanelCard — the shared React panel chrome (floating glass card + title bar) that
|
||||
* every ported panel renders into, the @hanzo/gui analogue of the vanilla `Panel`
|
||||
* base. One place owns the panel frame so individual panels carry only their body.
|
||||
*/
|
||||
export function PanelCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<YStack
|
||||
width={340}
|
||||
maxHeight="70vh"
|
||||
borderRadius="$4"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.12)"
|
||||
backgroundColor="rgba(12,12,14,0.82)"
|
||||
overflow="hidden"
|
||||
style={{ backdropFilter: 'blur(12px)' }}
|
||||
>
|
||||
<XStack
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
ai="center"
|
||||
jc="space-between"
|
||||
borderBottomWidth={1}
|
||||
borderColor="rgba(255,255,255,0.10)"
|
||||
>
|
||||
<SizableText size="$2" color="$color11" style={{ textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
{title}
|
||||
</SizableText>
|
||||
<XStack width={6} height={6} borderRadius={999} backgroundColor="#fff" opacity={0.7} />
|
||||
</XStack>
|
||||
<YStack px="$3" py="$2.5" overflow="scroll">
|
||||
{children}
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { XStack, SizableText } from '@hanzo/gui';
|
||||
import { VARIANT_TABS } from '../variants';
|
||||
|
||||
/**
|
||||
* VariantTabs — the header view switcher (Cloud · AI · Crypto · Finance · Tech ·
|
||||
* World), ported to React. Presentation only: it renders the canonical
|
||||
* `VARIANT_TABS` and reports the picked id upward; the actual switch (canonical
|
||||
* aliasing + persistence) stays owned by `@/config/variant`
|
||||
* (setSiteVariantRuntime), called once by the parent — one switch path.
|
||||
*/
|
||||
export function VariantTabs({
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
active: string;
|
||||
onSelect: (id: string) => void;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<XStack
|
||||
gap="$1"
|
||||
ai="center"
|
||||
role="tablist"
|
||||
aria-label="View switcher"
|
||||
borderRadius="$10"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.12)"
|
||||
p="$1"
|
||||
>
|
||||
{VARIANT_TABS.map((tab) => {
|
||||
const on = tab.id === active;
|
||||
return (
|
||||
<XStack
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={on}
|
||||
tag="button"
|
||||
focusable
|
||||
cursor="pointer"
|
||||
ai="center"
|
||||
gap="$1.5"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
borderRadius="$8"
|
||||
backgroundColor={on ? 'rgba(255,255,255,0.14)' : 'transparent'}
|
||||
hoverStyle={{ backgroundColor: 'rgba(255,255,255,0.08)' }}
|
||||
pressStyle={{ backgroundColor: 'rgba(255,255,255,0.18)' }}
|
||||
onPress={() => onSelect(tab.id)}
|
||||
>
|
||||
<SizableText size="$2" aria-hidden>
|
||||
{tab.icon}
|
||||
</SizableText>
|
||||
<SizableText size="$2" color={on ? '$color12' : '$color10'}>
|
||||
{tab.label}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import './theme.css';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { GuiProvider } from '@hanzo/gui';
|
||||
import guiConfig from './gui.config';
|
||||
import { App } from './App';
|
||||
|
||||
/**
|
||||
* React 19 entry for the world.hanzo.ai rewrite. This is a SECOND, isolated entry
|
||||
* (index.react.html → here) that runs alongside the shipping vanilla app — the
|
||||
* vanilla surface at index.html stays intact and deployable while the React +
|
||||
* @hanzo/gui foundation is built out behind ?react / the dedicated build.
|
||||
*
|
||||
* GuiProvider is the Tamagui runtime provider (from @hanzo/gui) fed the ONE config
|
||||
* (gui.config.ts). Everything below it can use @hanzogui/* primitives and the
|
||||
* unified shell.
|
||||
*/
|
||||
const host = document.getElementById('react-root');
|
||||
if (!host) throw new Error('[world/react] #react-root mount node missing');
|
||||
|
||||
createRoot(host).render(
|
||||
<StrictMode>
|
||||
<GuiProvider config={guiConfig} defaultTheme="dark">
|
||||
<App />
|
||||
</GuiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
// Canonical variant set for the React surface, mirroring the vanilla header
|
||||
// switcher (Cloud · AI · Crypto · Finance · Tech · World). The variant IDS and
|
||||
// aliasing stay owned by @/config/variant (getSiteVariant / setSiteVariantRuntime)
|
||||
// — this only carries the presentation (label + icon + order) for the React tabs,
|
||||
// so the switch logic remains one source of truth in the config layer.
|
||||
export interface VariantTab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const VARIANT_TABS: readonly VariantTab[] = [
|
||||
{ id: 'cloud', label: 'Cloud', icon: '☁️' },
|
||||
{ id: 'ai', label: 'AI', icon: '🤖' },
|
||||
{ id: 'crypto', label: 'Crypto', icon: '₿' },
|
||||
{ id: 'finance', label: 'Finance', icon: '📈' },
|
||||
{ id: 'tech', label: 'Tech', icon: '💻' },
|
||||
{ id: 'full', label: 'World', icon: '🌍' },
|
||||
] as const;
|
||||
Reference in New Issue
Block a user