diff --git a/packages/bolt/app/components/workbench/PortDropdown.tsx b/packages/bolt/app/components/workbench/PortDropdown.tsx new file mode 100644 index 0000000..13457b2 --- /dev/null +++ b/packages/bolt/app/components/workbench/PortDropdown.tsx @@ -0,0 +1,83 @@ +import { memo, useEffect, useRef } from 'react'; +import { IconButton } from '~/components/ui/IconButton'; +import type { PreviewInfo } from '~/lib/stores/previews'; + +interface PortDropdownProps { + activePreviewIndex: number; + setActivePreviewIndex: (index: number) => void; + isDropdownOpen: boolean; + setIsDropdownOpen: (value: boolean) => void; + setHasSelectedPreview: (value: boolean) => void; + previews: PreviewInfo[]; +} + +export const PortDropdown = memo( + ({ + activePreviewIndex, + setActivePreviewIndex, + isDropdownOpen, + setIsDropdownOpen, + setHasSelectedPreview, + previews, + }: PortDropdownProps) => { + const dropdownRef = useRef(null); + + // sort previews, preserving original index + const sortedPreviews = previews + .map((previewInfo, index) => ({ ...previewInfo, index })) + .sort((a, b) => a.port - b.port); + + // close dropdown if user clicks outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; + + if (isDropdownOpen) { + window.addEventListener('mousedown', handleClickOutside); + } else { + window.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + window.removeEventListener('mousedown', handleClickOutside); + }; + }, [isDropdownOpen]); + + return ( +
+ setIsDropdownOpen(!isDropdownOpen)} /> + {isDropdownOpen && ( +
+
+ Ports +
+ {sortedPreviews.map((preview) => ( +
{ + setActivePreviewIndex(preview.index); + setIsDropdownOpen(false); + setHasSelectedPreview(true); + }} + > + + {preview.port} + +
+ ))} +
+ )} +
+ ); + }, +); diff --git a/packages/bolt/app/components/workbench/Preview.tsx b/packages/bolt/app/components/workbench/Preview.tsx index 2d0a316..4c1877c 100644 --- a/packages/bolt/app/components/workbench/Preview.tsx +++ b/packages/bolt/app/components/workbench/Preview.tsx @@ -2,11 +2,14 @@ import { useStore } from '@nanostores/react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { IconButton } from '~/components/ui/IconButton'; import { workbenchStore } from '~/lib/stores/workbench'; +import { PortDropdown } from './PortDropdown'; export const Preview = memo(() => { const iframeRef = useRef(null); const inputRef = useRef(null); - const [activePreviewIndex] = useState(0); + const [activePreviewIndex, setActivePreviewIndex] = useState(0); + const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false); + const hasSelectedPreview = useRef(false); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; @@ -21,12 +24,10 @@ export const Preview = memo(() => { return; } - if (!iframeUrl) { - const { baseUrl } = activePreview; + const { baseUrl } = activePreview; - setUrl(baseUrl); - setIframeUrl(baseUrl); - } + setUrl(baseUrl); + setIframeUrl(baseUrl); }, [activePreview, iframeUrl]); const validateUrl = useCallback( @@ -48,6 +49,22 @@ export const Preview = memo(() => { [activePreview], ); + const findMinPortIndex = useCallback( + (minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => { + return preview.port < array[minIndex].port ? index : minIndex; + }, + [], + ); + + // when previews change, display the lowest port if user hasn't selected a preview + useEffect(() => { + if (previews.length > 1 && !hasSelectedPreview.current) { + const minPortIndex = previews.reduce(findMinPortIndex, 0); + + setActivePreviewIndex(minPortIndex); + } + }, [previews]); + const reloadPreview = () => { if (iframeRef.current) { iframeRef.current.src = iframeRef.current.src; @@ -56,6 +73,9 @@ export const Preview = memo(() => { return (
+ {isPortDropdownOpen && ( +
setIsPortDropdownOpen(false)} /> + )}
{ }} />
+ {previews.length > 1 && ( + (hasSelectedPreview.current = value)} + setIsDropdownOpen={setIsPortDropdownOpen} + previews={previews} + /> + )}
{activePreview ? ( diff --git a/packages/bolt/app/styles/animations.scss b/packages/bolt/app/styles/animations.scss index 29120aa..00cda0c 100644 --- a/packages/bolt/app/styles/animations.scss +++ b/packages/bolt/app/styles/animations.scss @@ -34,3 +34,16 @@ transform: translate3d(100%, 0, 0); } } + +.dropdown-animation { + opacity: 0; + animation: fadeMoveDown 0.15s forwards; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeMoveDown { + to { + opacity: 1; + transform: translateY(6px); + } +} diff --git a/packages/bolt/app/styles/z-index.scss b/packages/bolt/app/styles/z-index.scss index 713f308..b5150fc 100644 --- a/packages/bolt/app/styles/z-index.scss +++ b/packages/bolt/app/styles/z-index.scss @@ -8,6 +8,14 @@ $zIndexMax: 999; z-index: $zIndexMax - 2; } +.z-port-dropdown { + z-index: $zIndexMax - 3; +} + +.z-iframe-overlay { + z-index: $zIndexMax - 4; +} + .z-prompt { z-index: 2; }