page.tsx 11 KB

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