Dominic Elm commited on
Commit
a5ed695
·
unverified ·
1 Parent(s): 5db834e

refactor(workbench): add slider to switch between code or preview (#12)

Browse files
packages/bolt/app/components/ui/Slider.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from 'framer-motion';
2
+ import { memo } from 'react';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { genericMemo } from '~/utils/react';
5
+
6
+ interface SliderOption<T> {
7
+ value: T;
8
+ text: string;
9
+ }
10
+
11
+ export interface SliderOptions<T> {
12
+ left: SliderOption<T>;
13
+ right: SliderOption<T>;
14
+ }
15
+
16
+ interface SliderProps<T> {
17
+ selected: T;
18
+ options: SliderOptions<T>;
19
+ setSelected?: (selected: T) => void;
20
+ }
21
+
22
+ export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
23
+ const isLeftSelected = selected === options.left.value;
24
+
25
+ return (
26
+ <div className="flex items-center flex-wrap gap-1 border rounded-lg p-1">
27
+ <SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
28
+ {options.left.text}
29
+ </SliderButton>
30
+ <SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
31
+ {options.right.text}
32
+ </SliderButton>
33
+ </div>
34
+ );
35
+ });
36
+
37
+ interface SliderButtonProps {
38
+ selected: boolean;
39
+ children: string | JSX.Element | Array<JSX.Element | string>;
40
+ setSelected: () => void;
41
+ }
42
+
43
+ const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
44
+ return (
45
+ <button
46
+ onClick={setSelected}
47
+ className={classNames(
48
+ 'bg-transparent text-sm transition-colors px-2.5 py-0.5 rounded-md relative',
49
+ selected ? 'text-white' : 'text-gray-600 hover:text-accent-600 hover:bg-accent-600/10',
50
+ )}
51
+ >
52
+ <span className="relative z-10">{children}</span>
53
+ {selected && (
54
+ <motion.span
55
+ layoutId="pill-tab"
56
+ transition={{ type: 'spring', duration: 0.5 }}
57
+ className="absolute inset-0 z-0 bg-accent-600 rounded-md"
58
+ ></motion.span>
59
+ )}
60
+ </button>
61
+ );
62
+ });
packages/bolt/app/components/workbench/Preview.tsx CHANGED
@@ -36,13 +36,6 @@ export const Preview = memo(() => {
36
 
37
  return (
38
  <div className="w-full h-full flex flex-col">
39
- <div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
40
- <div className="flex items-center gap-2 text-gray-800">
41
- <div className="i-ph:app-window-duotone scale-130 ml-1.5" />
42
- <span className="text-sm">Preview</span>
43
- </div>
44
- <div className="flex-grow" />
45
- </div>
46
  <div className="bg-white p-2 flex items-center gap-1.5">
47
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
48
  <div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
 
36
 
37
  return (
38
  <div className="w-full h-full flex flex-col">
 
 
 
 
 
 
 
39
  <div className="bg-white p-2 flex items-center gap-1.5">
40
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
41
  <div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
packages/bolt/app/components/workbench/Workbench.client.tsx CHANGED
@@ -1,13 +1,14 @@
1
  import { useStore } from '@nanostores/react';
2
- import { AnimatePresence, motion, type Variants } from 'framer-motion';
3
- import { memo, useCallback, useEffect } from 'react';
4
- import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
5
  import { toast } from 'react-toastify';
6
  import {
7
  type OnChangeCallback as OnEditorChange,
8
  type OnScrollCallback as OnEditorScroll,
9
  } from '~/components/editor/codemirror/CodeMirrorEditor';
10
  import { IconButton } from '~/components/ui/IconButton';
 
11
  import { workbenchStore } from '~/lib/stores/workbench';
12
  import { cubicEasingFn } from '~/utils/easings';
13
  import { renderLogger } from '~/utils/logger';
@@ -19,6 +20,21 @@ interface WorkspaceProps {
19
  isStreaming?: boolean;
20
  }
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  const workbenchVariants = {
23
  closed: {
24
  width: 0,
@@ -39,13 +55,21 @@ const workbenchVariants = {
39
  export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
40
  renderLogger.trace('Workbench');
41
 
 
42
  const showWorkbench = useStore(workbenchStore.showWorkbench);
43
  const selectedFile = useStore(workbenchStore.selectedFile);
44
  const currentDocument = useStore(workbenchStore.currentDocument);
45
  const unsavedFiles = useStore(workbenchStore.unsavedFiles);
46
-
47
  const files = useStore(workbenchStore.files);
48
 
 
 
 
 
 
 
 
 
49
  useEffect(() => {
50
  workbenchStore.setDocuments(files);
51
  }, [files]);
@@ -79,7 +103,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
79
  <motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
80
  <div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
81
  <div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
82
- <div className="px-3 py-2 border-b border-gray-200">
 
83
  <IconButton
84
  icon="i-ph:x-circle"
85
  className="ml-auto -mr-1"
@@ -89,27 +114,30 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
89
  }}
90
  />
91
  </div>
92
- <div className="flex-1 overflow-hidden">
93
- <PanelGroup direction="vertical">
94
- <Panel defaultSize={50} minSize={20}>
95
- <EditorPanel
96
- editorDocument={currentDocument}
97
- isStreaming={isStreaming}
98
- selectedFile={selectedFile}
99
- files={files}
100
- unsavedFiles={unsavedFiles}
101
- onFileSelect={onFileSelect}
102
- onEditorScroll={onEditorScroll}
103
- onEditorChange={onEditorChange}
104
- onFileSave={onFileSave}
105
- onFileReset={onFileReset}
106
- />
107
- </Panel>
108
- <PanelResizeHandle />
109
- <Panel defaultSize={50} minSize={20}>
110
- <Preview />
111
- </Panel>
112
- </PanelGroup>
 
 
 
113
  </div>
114
  </div>
115
  </div>
@@ -119,3 +147,15 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
119
  )
120
  );
121
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useStore } from '@nanostores/react';
2
+ import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion';
3
+ import { computed } from 'nanostores';
4
+ import { memo, useCallback, useEffect, useState } from 'react';
5
  import { toast } from 'react-toastify';
6
  import {
7
  type OnChangeCallback as OnEditorChange,
8
  type OnScrollCallback as OnEditorScroll,
9
  } from '~/components/editor/codemirror/CodeMirrorEditor';
10
  import { IconButton } from '~/components/ui/IconButton';
11
+ import { Slider, type SliderOptions } from '~/components/ui/Slider';
12
  import { workbenchStore } from '~/lib/stores/workbench';
13
  import { cubicEasingFn } from '~/utils/easings';
14
  import { renderLogger } from '~/utils/logger';
 
20
  isStreaming?: boolean;
21
  }
22
 
23
+ type ViewType = 'code' | 'preview';
24
+
25
+ const viewTransition = { ease: cubicEasingFn };
26
+
27
+ const sliderOptions: SliderOptions<ViewType> = {
28
+ left: {
29
+ value: 'code',
30
+ text: 'Code',
31
+ },
32
+ right: {
33
+ value: 'preview',
34
+ text: 'Preview',
35
+ },
36
+ };
37
+
38
  const workbenchVariants = {
39
  closed: {
40
  width: 0,
 
55
  export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
56
  renderLogger.trace('Workbench');
57
 
58
+ const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
59
  const showWorkbench = useStore(workbenchStore.showWorkbench);
60
  const selectedFile = useStore(workbenchStore.selectedFile);
61
  const currentDocument = useStore(workbenchStore.currentDocument);
62
  const unsavedFiles = useStore(workbenchStore.unsavedFiles);
 
63
  const files = useStore(workbenchStore.files);
64
 
65
+ const [selectedView, setSelectedView] = useState<ViewType>(hasPreview ? 'preview' : 'code');
66
+
67
+ useEffect(() => {
68
+ if (hasPreview) {
69
+ setSelectedView('preview');
70
+ }
71
+ }, [hasPreview]);
72
+
73
  useEffect(() => {
74
  workbenchStore.setDocuments(files);
75
  }, [files]);
 
103
  <motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
104
  <div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
105
  <div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
106
+ <div className="flex items-center px-3 py-2 border-b border-gray-200">
107
+ <Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
108
  <IconButton
109
  icon="i-ph:x-circle"
110
  className="ml-auto -mr-1"
 
114
  }}
115
  />
116
  </div>
117
+ <div className="relative flex-1 overflow-hidden">
118
+ <View
119
+ initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
120
+ animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
121
+ >
122
+ <EditorPanel
123
+ editorDocument={currentDocument}
124
+ isStreaming={isStreaming}
125
+ selectedFile={selectedFile}
126
+ files={files}
127
+ unsavedFiles={unsavedFiles}
128
+ onFileSelect={onFileSelect}
129
+ onEditorScroll={onEditorScroll}
130
+ onEditorChange={onEditorChange}
131
+ onFileSave={onFileSave}
132
+ onFileReset={onFileReset}
133
+ />
134
+ </View>
135
+ <View
136
+ initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
137
+ animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
138
+ >
139
+ <Preview />
140
+ </View>
141
  </div>
142
  </div>
143
  </div>
 
147
  )
148
  );
149
  });
150
+
151
+ interface ViewProps extends HTMLMotionProps<'div'> {
152
+ children: JSX.Element;
153
+ }
154
+
155
+ const View = memo(({ children, ...props }: ViewProps) => {
156
+ return (
157
+ <motion.div className="absolute inset-0" transition={viewTransition} {...props}>
158
+ {children}
159
+ </motion.div>
160
+ );
161
+ });
packages/bolt/app/utils/react.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+
3
+ export const genericMemo: <T extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
4
+ component: T,
5
+ propsAreEqual?: (prevProps: React.ComponentProps<T>, nextProps: React.ComponentProps<T>) => boolean,
6
+ ) => T & { displayName?: string } = memo;