Connor Fogarty
commited on
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 |
-
|
25 |
-
const { baseUrl } = activePreview;
|
26 |
|
27 |
-
|
28 |
-
|
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 |
}
|