page.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. "use client";
  2. import {
  3. useCallback,
  4. useEffect,
  5. useRef,
  6. useState,
  7. useSyncExternalStore,
  8. } from "react";
  9. import { GaussianSplatViewer } from "./GaussianSplatViewer";
  10. import { scenes } from "./scenes";
  11. function highlightJson(json: string): string {
  12. return json.replace(
  13. /("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
  14. (_, key, colon, str, num, lit, punct) => {
  15. if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
  16. if (str) return `<span style="color:#00CA50">${str}</span>`;
  17. if (num) return `<span style="color:#47A8FF">${num}</span>`;
  18. if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
  19. if (punct) return `<span style="color:#666">${punct}</span>`;
  20. return _;
  21. },
  22. );
  23. }
  24. const MOBILE_BREAKPOINT = 768;
  25. function subscribeToResize(cb: () => void) {
  26. window.addEventListener("resize", cb);
  27. return () => window.removeEventListener("resize", cb);
  28. }
  29. function getIsMobile() {
  30. return typeof window !== "undefined"
  31. ? window.innerWidth < MOBILE_BREAKPOINT
  32. : false;
  33. }
  34. function useIsMobile() {
  35. return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
  36. }
  37. const LIST_WIDTH = 220;
  38. const JSON_WIDTH = 380;
  39. const HEADER_HEIGHT = 40;
  40. const headerStyle: React.CSSProperties = {
  41. height: HEADER_HEIGHT,
  42. display: "flex",
  43. alignItems: "center",
  44. padding: "0 16px",
  45. fontSize: 11,
  46. fontWeight: 600,
  47. color: "#555",
  48. letterSpacing: "0.08em",
  49. textTransform: "uppercase",
  50. fontFamily: "ui-monospace, monospace",
  51. borderBottom: "1px solid #1e1e1e",
  52. flexShrink: 0,
  53. whiteSpace: "nowrap",
  54. overflow: "hidden",
  55. textOverflow: "ellipsis",
  56. boxSizing: "border-box",
  57. };
  58. function SplatViewer({ sceneIndex }: { sceneIndex: number }) {
  59. const scene = scenes[sceneIndex]!;
  60. const v = scene.viewer;
  61. return (
  62. <GaussianSplatViewer
  63. width="100%"
  64. height="100%"
  65. backgroundColor={v.backgroundColor ?? "#0a0a0a"}
  66. controls={v.controls ?? true}
  67. autoRotate={v.autoRotate ?? true}
  68. autoRotateSpeed={v.autoRotateSpeed ?? 0.5}
  69. cameraPosition={v.cameraPosition ?? [0, 2, 5]}
  70. cameraTarget={v.cameraTarget ?? [0, 0, 0]}
  71. fov={v.fov ?? 50}
  72. splats={scene.splats}
  73. />
  74. );
  75. }
  76. function SceneListItem({
  77. scene,
  78. index,
  79. isSelected,
  80. onSelect,
  81. }: {
  82. scene: (typeof scenes)[number];
  83. index: number;
  84. isSelected: boolean;
  85. onSelect: (i: number) => void;
  86. }) {
  87. return (
  88. <button
  89. role="option"
  90. aria-selected={isSelected}
  91. onClick={() => onSelect(index)}
  92. style={{
  93. display: "block",
  94. width: "100%",
  95. padding: "8px 16px",
  96. fontSize: 13,
  97. border: "none",
  98. textAlign: "left",
  99. background: isSelected ? "rgba(255,255,255,0.08)" : "transparent",
  100. color: isSelected ? "#fff" : "#888",
  101. fontWeight: isSelected ? 500 : 400,
  102. cursor: "pointer",
  103. borderLeft: isSelected ? "2px solid #fff" : "2px solid transparent",
  104. fontFamily: "inherit",
  105. }}
  106. >
  107. {scene.name}
  108. </button>
  109. );
  110. }
  111. function DesktopLayout() {
  112. const [selectedIndex, setSelectedIndex] = useState(0);
  113. const selected = scenes[selectedIndex]!;
  114. return (
  115. <div style={{ height: "100dvh", display: "flex", background: "#0a0a0a" }}>
  116. <nav
  117. aria-label="Splat scenes"
  118. style={{
  119. width: LIST_WIDTH,
  120. flexShrink: 0,
  121. display: "flex",
  122. flexDirection: "column",
  123. borderRight: "1px solid #1e1e1e",
  124. background: "#0f0f0f",
  125. }}
  126. >
  127. <div style={headerStyle}>Splat Scenes</div>
  128. <div
  129. role="listbox"
  130. aria-label="Scene list"
  131. style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}
  132. >
  133. {scenes.map((scene, i) => (
  134. <SceneListItem
  135. key={scene.name}
  136. scene={scene}
  137. index={i}
  138. isSelected={i === selectedIndex}
  139. onSelect={setSelectedIndex}
  140. />
  141. ))}
  142. </div>
  143. </nav>
  144. <main
  145. style={{
  146. flex: 1,
  147. display: "flex",
  148. flexDirection: "column",
  149. minWidth: 0,
  150. }}
  151. >
  152. <div style={headerStyle}>{selected.description}</div>
  153. <div style={{ flex: 1, background: "#000", position: "relative" }}>
  154. <SplatViewer key={selectedIndex} sceneIndex={selectedIndex} />
  155. </div>
  156. </main>
  157. <aside
  158. aria-label="Spec JSON"
  159. style={{
  160. width: JSON_WIDTH,
  161. flexShrink: 0,
  162. display: "flex",
  163. flexDirection: "column",
  164. borderLeft: "1px solid #1e1e1e",
  165. background: "#0d0d0d",
  166. }}
  167. >
  168. <div style={headerStyle}>Spec JSON</div>
  169. <pre
  170. style={{
  171. flex: 1,
  172. margin: 0,
  173. padding: 14,
  174. overflowY: "auto",
  175. overflowX: "auto",
  176. fontSize: 11,
  177. lineHeight: 1.6,
  178. fontFamily:
  179. "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
  180. color: "#EDEDED",
  181. tabSize: 2,
  182. }}
  183. dangerouslySetInnerHTML={{
  184. __html: highlightJson(
  185. JSON.stringify(
  186. { spec: selected.spec, splats: selected.splats },
  187. null,
  188. 2,
  189. ),
  190. ),
  191. }}
  192. />
  193. </aside>
  194. </div>
  195. );
  196. }
  197. function MobileLayout() {
  198. const [selectedIndex, setSelectedIndex] = useState(0);
  199. const [showJson, setShowJson] = useState(false);
  200. const [showScenes, setShowScenes] = useState(false);
  201. const selected = scenes[selectedIndex]!;
  202. const scenePanelRef = useRef<HTMLDivElement>(null);
  203. const jsonPanelRef = useRef<HTMLDivElement>(null);
  204. const closeScenes = useCallback(() => setShowScenes(false), []);
  205. const closeJson = useCallback(() => setShowJson(false), []);
  206. // Close overlays on Escape
  207. useEffect(() => {
  208. const onKeyDown = (e: KeyboardEvent) => {
  209. if (e.key === "Escape") {
  210. if (showScenes) closeScenes();
  211. if (showJson) closeJson();
  212. }
  213. };
  214. window.addEventListener("keydown", onKeyDown);
  215. return () => window.removeEventListener("keydown", onKeyDown);
  216. }, [showScenes, showJson, closeScenes, closeJson]);
  217. // Focus the panel when it opens
  218. useEffect(() => {
  219. if (showScenes) scenePanelRef.current?.focus();
  220. }, [showScenes]);
  221. useEffect(() => {
  222. if (showJson) jsonPanelRef.current?.focus();
  223. }, [showJson]);
  224. return (
  225. <div
  226. style={{
  227. height: "100dvh",
  228. display: "flex",
  229. flexDirection: "column",
  230. background: "#0a0a0a",
  231. overflow: "hidden",
  232. }}
  233. >
  234. <header
  235. style={{
  236. height: 48,
  237. display: "flex",
  238. alignItems: "center",
  239. justifyContent: "space-between",
  240. padding: "0 12px",
  241. borderBottom: "1px solid #1e1e1e",
  242. background: "#0f0f0f",
  243. flexShrink: 0,
  244. gap: 8,
  245. }}
  246. >
  247. <button
  248. aria-label={`Scene: ${selected.name}. Open scene list`}
  249. aria-expanded={showScenes}
  250. onClick={() => {
  251. setShowScenes((v) => !v);
  252. setShowJson(false);
  253. }}
  254. style={{
  255. background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
  256. border: "1px solid #333",
  257. borderRadius: 6,
  258. color: "#ccc",
  259. fontSize: 12,
  260. fontWeight: 500,
  261. padding: "6px 10px",
  262. cursor: "pointer",
  263. fontFamily: "inherit",
  264. whiteSpace: "nowrap",
  265. overflow: "hidden",
  266. textOverflow: "ellipsis",
  267. maxWidth: "50%",
  268. }}
  269. >
  270. {selected.name}
  271. </button>
  272. <span
  273. style={{
  274. flex: 1,
  275. fontSize: 10,
  276. color: "#555",
  277. textAlign: "center",
  278. overflow: "hidden",
  279. textOverflow: "ellipsis",
  280. whiteSpace: "nowrap",
  281. fontFamily: "ui-monospace, monospace",
  282. textTransform: "uppercase",
  283. letterSpacing: "0.06em",
  284. }}
  285. >
  286. {selected.description}
  287. </span>
  288. <button
  289. aria-label="Toggle JSON panel"
  290. aria-expanded={showJson}
  291. onClick={() => {
  292. setShowJson((v) => !v);
  293. setShowScenes(false);
  294. }}
  295. style={{
  296. background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
  297. border: "1px solid #333",
  298. borderRadius: 6,
  299. color: "#ccc",
  300. fontSize: 11,
  301. fontWeight: 500,
  302. padding: "6px 10px",
  303. cursor: "pointer",
  304. fontFamily: "ui-monospace, monospace",
  305. whiteSpace: "nowrap",
  306. letterSpacing: "0.04em",
  307. }}
  308. >
  309. JSON
  310. </button>
  311. </header>
  312. <div style={{ flex: 1, position: "relative", minHeight: 0 }}>
  313. <SplatViewer key={selectedIndex} sceneIndex={selectedIndex} />
  314. {showScenes && (
  315. <>
  316. <div
  317. onClick={closeScenes}
  318. style={{
  319. position: "absolute",
  320. inset: 0,
  321. background: "rgba(0,0,0,0.5)",
  322. zIndex: 10,
  323. }}
  324. />
  325. <div
  326. ref={scenePanelRef}
  327. role="dialog"
  328. aria-modal="true"
  329. aria-label="Scene list"
  330. tabIndex={-1}
  331. style={{
  332. position: "absolute",
  333. top: 0,
  334. left: 0,
  335. bottom: 0,
  336. width: "75%",
  337. maxWidth: 280,
  338. background: "#0f0f0f",
  339. borderRight: "1px solid #1e1e1e",
  340. zIndex: 11,
  341. display: "flex",
  342. flexDirection: "column",
  343. overflowY: "auto",
  344. }}
  345. >
  346. <div style={{ ...headerStyle, height: 36 }}>Splat Scenes</div>
  347. <div
  348. role="listbox"
  349. aria-label="Scene list"
  350. style={{ padding: "6px 0" }}
  351. >
  352. {scenes.map((scene, i) => (
  353. <SceneListItem
  354. key={scene.name}
  355. scene={scene}
  356. index={i}
  357. isSelected={i === selectedIndex}
  358. onSelect={(idx) => {
  359. setSelectedIndex(idx);
  360. closeScenes();
  361. }}
  362. />
  363. ))}
  364. </div>
  365. </div>
  366. </>
  367. )}
  368. {showJson && (
  369. <>
  370. <div
  371. onClick={closeJson}
  372. style={{
  373. position: "absolute",
  374. inset: 0,
  375. background: "rgba(0,0,0,0.5)",
  376. zIndex: 10,
  377. }}
  378. />
  379. <div
  380. ref={jsonPanelRef}
  381. role="dialog"
  382. aria-modal="true"
  383. aria-label="Spec JSON"
  384. tabIndex={-1}
  385. style={{
  386. position: "absolute",
  387. top: 0,
  388. right: 0,
  389. bottom: 0,
  390. width: "85%",
  391. maxWidth: 400,
  392. background: "#0d0d0d",
  393. borderLeft: "1px solid #1e1e1e",
  394. zIndex: 11,
  395. display: "flex",
  396. flexDirection: "column",
  397. }}
  398. >
  399. <div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
  400. <pre
  401. style={{
  402. flex: 1,
  403. margin: 0,
  404. padding: 14,
  405. overflowY: "auto",
  406. overflowX: "auto",
  407. fontSize: 11,
  408. lineHeight: 1.6,
  409. fontFamily:
  410. "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
  411. color: "#EDEDED",
  412. tabSize: 2,
  413. }}
  414. dangerouslySetInnerHTML={{
  415. __html: highlightJson(
  416. JSON.stringify(
  417. { spec: selected.spec, splats: selected.splats },
  418. null,
  419. 2,
  420. ),
  421. ),
  422. }}
  423. />
  424. </div>
  425. </>
  426. )}
  427. </div>
  428. </div>
  429. );
  430. }
  431. export default function Page() {
  432. const isMobile = useIsMobile();
  433. return isMobile ? <MobileLayout /> : <DesktopLayout />;
  434. }