editor.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. "use client";
  2. import {
  3. useCallback,
  4. useEffect,
  5. useMemo,
  6. useRef,
  7. useState,
  8. type MouseEvent,
  9. } from "react";
  10. import { JsonEditor, type JsonValue } from "@visual-json/react";
  11. import type { NextAppSpec } from "@json-render/next";
  12. import { NextAppProvider, PageRenderer } from "@json-render/next";
  13. import {
  14. ResizablePanelGroup,
  15. ResizablePanel,
  16. ResizableHandle,
  17. } from "@/components/ui/resizable";
  18. import { AddressBar } from "@/components/route-tabs";
  19. import { registry } from "@/lib/registry";
  20. export function Editor() {
  21. const [spec, setSpec] = useState<NextAppSpec | null>(null);
  22. const [activeRoute, setActiveRoute] = useState("/");
  23. const [sidebarOpen, setSidebarOpen] = useState(true);
  24. const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  25. useEffect(() => {
  26. fetch("/api/spec")
  27. .then((r) => r.json())
  28. .then((data: NextAppSpec) => setSpec(data));
  29. }, []);
  30. const handleChange = useCallback((value: JsonValue) => {
  31. const updated = value as unknown as NextAppSpec;
  32. setSpec(updated);
  33. if (debounceRef.current) clearTimeout(debounceRef.current);
  34. debounceRef.current = setTimeout(() => {
  35. fetch("/api/spec", {
  36. method: "PUT",
  37. headers: { "Content-Type": "application/json" },
  38. body: JSON.stringify(updated),
  39. });
  40. }, 500);
  41. }, []);
  42. const handlePreviewClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
  43. const anchor = (e.target as HTMLElement).closest("a");
  44. if (!anchor) return;
  45. const href = anchor.getAttribute("href");
  46. if (!href || href.startsWith("http") || href.startsWith("mailto:")) return;
  47. e.preventDefault();
  48. setActiveRoute(href);
  49. }, []);
  50. const currentRoute = useMemo(() => {
  51. if (!spec) return null;
  52. return spec.routes[activeRoute] ?? null;
  53. }, [spec, activeRoute]);
  54. const layoutSpec = useMemo(() => {
  55. if (!spec || !currentRoute?.layout || !spec.layouts) return null;
  56. return spec.layouts[currentRoute.layout] ?? null;
  57. }, [spec, currentRoute]);
  58. const initialState = useMemo(() => {
  59. if (!spec || !currentRoute) return undefined;
  60. const merged: Record<string, unknown> = {};
  61. if (spec.state) Object.assign(merged, spec.state);
  62. if (currentRoute.page.state) Object.assign(merged, currentRoute.page.state);
  63. return Object.keys(merged).length > 0 ? merged : undefined;
  64. }, [spec, currentRoute]);
  65. if (!spec) {
  66. return (
  67. <div className="flex items-center justify-center h-screen text-muted-foreground">
  68. Loading...
  69. </div>
  70. );
  71. }
  72. return (
  73. <div className="h-screen flex flex-col">
  74. <div className="flex items-center justify-between px-4 h-12 border-b border-border bg-background shrink-0">
  75. <span className="text-sm font-semibold">Next Website Builder</span>
  76. <a
  77. href="/"
  78. target="_blank"
  79. rel="noopener noreferrer"
  80. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  81. >
  82. View Website
  83. </a>
  84. </div>
  85. <ResizablePanelGroup orientation="horizontal" className="flex-1">
  86. <ResizablePanel defaultSize={45} minSize={25}>
  87. <div className="h-full flex flex-col">
  88. <div className="flex items-center justify-between px-3 h-10 border-b border-border bg-muted/30">
  89. <span className="text-xs font-mono text-muted-foreground">
  90. spec.json
  91. </span>
  92. <button
  93. onClick={() => setSidebarOpen((v) => !v)}
  94. className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
  95. title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
  96. >
  97. <svg
  98. width="14"
  99. height="14"
  100. viewBox="0 0 24 24"
  101. fill="none"
  102. stroke="currentColor"
  103. strokeWidth="2"
  104. strokeLinecap="round"
  105. strokeLinejoin="round"
  106. >
  107. <rect x="3" y="3" width="18" height="18" rx="2" />
  108. <path d="M9 3v18" />
  109. </svg>
  110. </button>
  111. </div>
  112. <div className="flex-1 overflow-auto">
  113. <JsonEditor
  114. value={spec as unknown as JsonValue}
  115. onChange={handleChange}
  116. sidebarOpen={sidebarOpen}
  117. height="100%"
  118. className="h-full"
  119. style={
  120. {
  121. "--vj-bg": "var(--background)",
  122. "--vj-bg-panel": "var(--background)",
  123. "--vj-bg-hover": "var(--muted)",
  124. "--vj-bg-selected": "var(--primary)",
  125. "--vj-bg-selected-muted": "var(--muted)",
  126. "--vj-text": "var(--foreground)",
  127. "--vj-text-selected": "var(--primary-foreground)",
  128. "--vj-text-muted": "var(--muted-foreground)",
  129. "--vj-text-dim": "var(--muted-foreground)",
  130. "--vj-border": "var(--border)",
  131. "--vj-border-subtle": "var(--border)",
  132. "--vj-accent": "var(--primary)",
  133. "--vj-accent-muted": "var(--muted)",
  134. "--vj-input-bg": "var(--secondary)",
  135. "--vj-input-border": "var(--border)",
  136. } as React.CSSProperties
  137. }
  138. />
  139. </div>
  140. </div>
  141. </ResizablePanel>
  142. <ResizableHandle withHandle />
  143. <ResizablePanel defaultSize={55} minSize={30}>
  144. <div className="h-full flex flex-col">
  145. <AddressBar route={activeRoute} onNavigate={setActiveRoute} />
  146. <div
  147. className="flex-1 overflow-auto bg-background"
  148. onClick={handlePreviewClick}
  149. >
  150. {currentRoute ? (
  151. <NextAppProvider registry={registry}>
  152. <PageRenderer
  153. spec={currentRoute.page}
  154. initialState={initialState}
  155. layoutSpec={layoutSpec}
  156. />
  157. </NextAppProvider>
  158. ) : (
  159. <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
  160. Route not found
  161. </div>
  162. )}
  163. </div>
  164. </div>
  165. </ResizablePanel>
  166. </ResizablePanelGroup>
  167. </div>
  168. );
  169. }