Connor Fogarty commited on
Commit
f55b4e5
·
unverified ·
1 Parent(s): 5f06f50

feat: add dropdown to select preview port (#17)

Browse files
packages/bolt/app/components/workbench/PortDropdown.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useEffect, useRef } from 'react';
2
+ import { IconButton } from '~/components/ui/IconButton';
3
+ import type { PreviewInfo } from '~/lib/stores/previews';
4
+
5
+ interface PortDropdownProps {
6
+ activePreviewIndex: number;
7
+ setActivePreviewIndex: (index: number) => void;
8
+ isDropdownOpen: boolean;
9
+ setIsDropdownOpen: (value: boolean) => void;
10
+ setHasSelectedPreview: (value: boolean) => void;
11
+ previews: PreviewInfo[];
12
+ }
13
+
14
+ export const PortDropdown = memo(
15
+ ({
16
+ activePreviewIndex,
17
+ setActivePreviewIndex,
18
+ isDropdownOpen,
19
+ setIsDropdownOpen,
20
+ setHasSelectedPreview,
21
+ previews,
22
+ }: PortDropdownProps) => {
23
+ const dropdownRef = useRef<HTMLDivElement>(null);
24
+
25
+ // sort previews, preserving original index
26
+ const sortedPreviews = previews
27
+ .map((previewInfo, index) => ({ ...previewInfo, index }))
28
+ .sort((a, b) => a.port - b.port);
29
+
30
+ // close dropdown if user clicks outside
31
+ useEffect(() => {
32
+ const handleClickOutside = (event: MouseEvent) => {
33
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
34
+ setIsDropdownOpen(false);
35
+ }
36
+ };
37
+
38
+ if (isDropdownOpen) {
39
+ window.addEventListener('mousedown', handleClickOutside);
40
+ } else {
41
+ window.removeEventListener('mousedown', handleClickOutside);
42
+ }
43
+
44
+ return () => {
45
+ window.removeEventListener('mousedown', handleClickOutside);
46
+ };
47
+ }, [isDropdownOpen]);
48
+
49
+ return (
50
+ <div className="relative z-port-dropdown" ref={dropdownRef}>
51
+ <IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
52
+ {isDropdownOpen && (
53
+ <div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
54
+ <div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
55
+ Ports
56
+ </div>
57
+ {sortedPreviews.map((preview) => (
58
+ <div
59
+ key={preview.port}
60
+ className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
61
+ onClick={() => {
62
+ setActivePreviewIndex(preview.index);
63
+ setIsDropdownOpen(false);
64
+ setHasSelectedPreview(true);
65
+ }}
66
+ >
67
+ <span
68
+ className={
69
+ activePreviewIndex === preview.index
70
+ ? 'text-bolt-elements-item-contentAccent'
71
+ : 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
72
+ }
73
+ >
74
+ {preview.port}
75
+ </span>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </div>
81
+ );
82
+ },
83
+ );
packages/bolt/app/components/workbench/Preview.tsx CHANGED
@@ -2,11 +2,14 @@ import { useStore } from '@nanostores/react';
2
  import { memo, useCallback, useEffect, useRef, useState } from 'react';
3
  import { IconButton } from '~/components/ui/IconButton';
4
  import { workbenchStore } from '~/lib/stores/workbench';
 
5
 
6
  export const Preview = memo(() => {
7
  const iframeRef = useRef<HTMLIFrameElement>(null);
8
  const inputRef = useRef<HTMLInputElement>(null);
9
- const [activePreviewIndex] = useState(0);
 
 
10
  const previews = useStore(workbenchStore.previews);
11
  const activePreview = previews[activePreviewIndex];
12
 
@@ -21,12 +24,10 @@ export const Preview = memo(() => {
21
  return;
22
  }
23
 
24
- if (!iframeUrl) {
25
- const { baseUrl } = activePreview;
26
 
27
- setUrl(baseUrl);
28
- setIframeUrl(baseUrl);
29
- }
30
  }, [activePreview, iframeUrl]);
31
 
32
  const validateUrl = useCallback(
@@ -48,6 +49,22 @@ export const Preview = memo(() => {
48
  [activePreview],
49
  );
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  const reloadPreview = () => {
52
  if (iframeRef.current) {
53
  iframeRef.current.src = iframeRef.current.src;
@@ -56,6 +73,9 @@ export const Preview = memo(() => {
56
 
57
  return (
58
  <div className="w-full h-full flex flex-col">
 
 
 
59
  <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
60
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
61
  <div
@@ -81,6 +101,16 @@ export const Preview = memo(() => {
81
  }}
82
  />
83
  </div>
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
  <div className="flex-1 border-t border-bolt-elements-borderColor">
86
  {activePreview ? (
 
2
  import { memo, useCallback, useEffect, useRef, useState } from 'react';
3
  import { IconButton } from '~/components/ui/IconButton';
4
  import { workbenchStore } from '~/lib/stores/workbench';
5
+ import { PortDropdown } from './PortDropdown';
6
 
7
  export const Preview = memo(() => {
8
  const iframeRef = useRef<HTMLIFrameElement>(null);
9
  const inputRef = useRef<HTMLInputElement>(null);
10
+ const [activePreviewIndex, setActivePreviewIndex] = useState(0);
11
+ const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
12
+ const hasSelectedPreview = useRef(false);
13
  const previews = useStore(workbenchStore.previews);
14
  const activePreview = previews[activePreviewIndex];
15
 
 
24
  return;
25
  }
26
 
27
+ const { baseUrl } = activePreview;
 
28
 
29
+ setUrl(baseUrl);
30
+ setIframeUrl(baseUrl);
 
31
  }, [activePreview, iframeUrl]);
32
 
33
  const validateUrl = useCallback(
 
49
  [activePreview],
50
  );
51
 
52
+ const findMinPortIndex = useCallback(
53
+ (minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
54
+ return preview.port < array[minIndex].port ? index : minIndex;
55
+ },
56
+ [],
57
+ );
58
+
59
+ // when previews change, display the lowest port if user hasn't selected a preview
60
+ useEffect(() => {
61
+ if (previews.length > 1 && !hasSelectedPreview.current) {
62
+ const minPortIndex = previews.reduce(findMinPortIndex, 0);
63
+
64
+ setActivePreviewIndex(minPortIndex);
65
+ }
66
+ }, [previews]);
67
+
68
  const reloadPreview = () => {
69
  if (iframeRef.current) {
70
  iframeRef.current.src = iframeRef.current.src;
 
73
 
74
  return (
75
  <div className="w-full h-full flex flex-col">
76
+ {isPortDropdownOpen && (
77
+ <div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
78
+ )}
79
  <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
80
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
81
  <div
 
101
  }}
102
  />
103
  </div>
104
+ {previews.length > 1 && (
105
+ <PortDropdown
106
+ activePreviewIndex={activePreviewIndex}
107
+ setActivePreviewIndex={setActivePreviewIndex}
108
+ isDropdownOpen={isPortDropdownOpen}
109
+ setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
110
+ setIsDropdownOpen={setIsPortDropdownOpen}
111
+ previews={previews}
112
+ />
113
+ )}
114
  </div>
115
  <div className="flex-1 border-t border-bolt-elements-borderColor">
116
  {activePreview ? (
packages/bolt/app/styles/animations.scss CHANGED
@@ -34,3 +34,16 @@
34
  transform: translate3d(100%, 0, 0);
35
  }
36
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  transform: translate3d(100%, 0, 0);
35
  }
36
  }
37
+
38
+ .dropdown-animation {
39
+ opacity: 0;
40
+ animation: fadeMoveDown 0.15s forwards;
41
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
42
+ }
43
+
44
+ @keyframes fadeMoveDown {
45
+ to {
46
+ opacity: 1;
47
+ transform: translateY(6px);
48
+ }
49
+ }
packages/bolt/app/styles/z-index.scss CHANGED
@@ -8,6 +8,14 @@ $zIndexMax: 999;
8
  z-index: $zIndexMax - 2;
9
  }
10
 
 
 
 
 
 
 
 
 
11
  .z-prompt {
12
  z-index: 2;
13
  }
 
8
  z-index: $zIndexMax - 2;
9
  }
10
 
11
+ .z-port-dropdown {
12
+ z-index: $zIndexMax - 3;
13
+ }
14
+
15
+ .z-iframe-overlay {
16
+ z-index: $zIndexMax - 4;
17
+ }
18
+
19
  .z-prompt {
20
  z-index: 2;
21
  }