Bin
2025-12-16 9e0b2ba2c317b1a86212f24cbae3195ad1f3dbfa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { MouseEvent } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { ToastProvider, ToastViewport } from "@humansignal/ui/lib/toast/toast";
import { cnm } from "@humansignal/shad/utils";
import { PreviewPanel } from "../PreviewPanel";
import { EditorPanel } from "../EditorPanel";
import { TopBar } from "./TopBar";
import { configAtom, loadingAtom, errorAtom, interfacesAtom, displayModeAtom } from "../../atoms/configAtoms";
import {
  getQueryParams,
  replaceBrTagsWithNewlines,
  getInterfacesFromParams,
  throwUnlessXmlLike,
} from "../../utils/query";
import styles from "./PlaygroundApp.module.scss";
 
const DEFAULT_EDITOR_WIDTH_PERCENT = 50;
const MIN_EDITOR_WIDTH_PERCENT = 20;
const MAX_EDITOR_WIDTH_PERCENT = 80;
 
export const PlaygroundApp = () => {
  const setConfig = useSetAtom(configAtom);
  const setLoading = useSetAtom(loadingAtom);
  const setError = useSetAtom(errorAtom);
  const setInterfaces = useSetAtom(interfacesAtom);
  const displayMode = useAtomValue(displayModeAtom);
  const [editorWidth, setEditorWidth] = useState(DEFAULT_EDITOR_WIDTH_PERCENT);
  const dragging = useRef(false);
 
  useEffect(() => {
    const params = getQueryParams();
    const configParam = params.get("config");
    const configUrl = params.get("configUrl");
    setInterfaces(getInterfacesFromParams(params));
 
    async function loadConfig() {
      let config = null;
 
      // Precedence: configUrl > configParam
      if (configUrl) {
        setLoading(true);
        try {
          const res = await fetch(configUrl);
          if (!res.ok) throw new Error("Failed to fetch config from URL.");
          const text = await res.text();
          // Replace all <br> tags with newlines
          config = replaceBrTagsWithNewlines(text);
        } catch (e) {
          setError("Failed to fetch config from URL.");
        } finally {
          setLoading(false);
        }
      }
 
      config ??= configParam;
 
      if (config) {
        try {
          // Check if the config is already valid xml
          // Otherwise, parse url encoded config
          // Replace all <br> tags with newlines
          try {
            throwUnlessXmlLike(config);
            setConfig(replaceBrTagsWithNewlines(config));
          } catch (e) {
            setConfig(replaceBrTagsWithNewlines(decodeURIComponent(config)));
          }
        } catch (e) {
          setError("Failed to decode config. Are you sure it's a valid urlencoded string?");
        }
        return;
      }
    }
    loadConfig();
    // eslint-disable-next-line
  }, [setConfig, setError, setLoading, setInterfaces]);
 
  // Draggable divider logic
  useEffect(() => {
    const onMouseMove = (e: MouseEvent) => {
      if (!dragging.current) return;
      e.preventDefault();
      document.body.style.cursor = "col-resize";
      document.body.style.userSelect = "none";
      const percent = (e.clientX / window.innerWidth) * 100;
      setEditorWidth(Math.max(MIN_EDITOR_WIDTH_PERCENT, Math.min(MAX_EDITOR_WIDTH_PERCENT, percent)));
    };
    const onMouseUp = () => {
      dragging.current = false;
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
    };
    window.addEventListener("mousemove", onMouseMove as unknown as EventListener);
    window.addEventListener("mouseup", onMouseUp as unknown as EventListener);
    return () => {
      window.removeEventListener("mousemove", onMouseMove as unknown as EventListener);
      window.removeEventListener("mouseup", onMouseUp as unknown as EventListener);
    };
  }, []);
 
  const handleDividerDoubleClick = useCallback(
    (e: MouseEvent) => {
      e.preventDefault();
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
      setEditorWidth(DEFAULT_EDITOR_WIDTH_PERCENT);
    },
    [setEditorWidth],
  );
 
  const previewPanelStyle = useMemo(() => ({ width: `${100 - editorWidth}%` }), [editorWidth]);
 
  return (
    <div
      className={cnm("flex flex-col h-screen w-screen", {
        [styles.root]: true,
      })}
    >
      <ToastProvider>
        {/* Minimal top bar */}
        {!displayMode.startsWith("preview") && <TopBar />}
        {/* Editor/Preview split */}
        <div className="flex flex-1 min-h-0 min-w-0 relative">
          {/* Editor Panel */}
          {!displayMode.startsWith("preview") && <EditorPanel editorWidth={editorWidth} />}
          {/* Resizable Divider */}
          {!displayMode.startsWith("preview") && (
            <div
              className="w-2 cursor-col-resize bg-neutral-emphasis hover:bg-primary-border active:bg-primary-border transition-colors duration-100 z-10"
              onMouseDown={(e: MouseEvent) => {
                if (e.button !== 0) return;
                e.preventDefault();
                dragging.current = true;
              }}
              onDoubleClick={handleDividerDoubleClick}
              role="separator"
              aria-orientation="vertical"
              tabIndex={-1}
            />
          )}
 
          {/* Preview Panel */}
          <div
            className={cnm("flex flex-col min-w-0 h-full", {
              "flex-row flex-1 w-full": displayMode !== "all",
            })}
            style={previewPanelStyle}
          >
            <div className="flex-1 min-h-0 min-w-0">
              <PreviewPanel />
            </div>
          </div>
        </div>
        <ToastViewport />
      </ToastProvider>
    </div>
  );
};