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

This commit is contained in:
Connor Fogarty 2024-08-20 02:17:34 -05:00 committed by GitHub
parent 5f06f508cd
commit f55b4e5ab9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 140 additions and 6 deletions

View File

@ -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<HTMLDivElement>(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 (
<div className="relative z-port-dropdown" ref={dropdownRef}>
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
{isDropdownOpen && (
<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">
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
Ports
</div>
{sortedPreviews.map((preview) => (
<div
key={preview.port}
className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
onClick={() => {
setActivePreviewIndex(preview.index);
setIsDropdownOpen(false);
setHasSelectedPreview(true);
}}
>
<span
className={
activePreviewIndex === preview.index
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
}
>
{preview.port}
</span>
</div>
))}
</div>
)}
</div>
);
},
);

View File

@ -2,11 +2,14 @@ import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton'; import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
export const Preview = memo(() => { export const Preview = memo(() => {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(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 previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex]; const activePreview = previews[activePreviewIndex];
@ -21,12 +24,10 @@ export const Preview = memo(() => {
return; return;
} }
if (!iframeUrl) { const { baseUrl } = activePreview;
const { baseUrl } = activePreview;
setUrl(baseUrl); setUrl(baseUrl);
setIframeUrl(baseUrl); setIframeUrl(baseUrl);
}
}, [activePreview, iframeUrl]); }, [activePreview, iframeUrl]);
const validateUrl = useCallback( const validateUrl = useCallback(
@ -48,6 +49,22 @@ export const Preview = memo(() => {
[activePreview], [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 = () => { const reloadPreview = () => {
if (iframeRef.current) { if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src; iframeRef.current.src = iframeRef.current.src;
@ -56,6 +73,9 @@ export const Preview = memo(() => {
return ( return (
<div className="w-full h-full flex flex-col"> <div className="w-full h-full flex flex-col">
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5"> <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} /> <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<div <div
@ -81,6 +101,16 @@ export const Preview = memo(() => {
}} }}
/> />
</div> </div>
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
</div> </div>
<div className="flex-1 border-t border-bolt-elements-borderColor"> <div className="flex-1 border-t border-bolt-elements-borderColor">
{activePreview ? ( {activePreview ? (

View File

@ -34,3 +34,16 @@
transform: translate3d(100%, 0, 0); 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);
}
}

View File

@ -8,6 +8,14 @@ $zIndexMax: 999;
z-index: $zIndexMax - 2; z-index: $zIndexMax - 2;
} }
.z-port-dropdown {
z-index: $zIndexMax - 3;
}
.z-iframe-overlay {
z-index: $zIndexMax - 4;
}
.z-prompt { .z-prompt {
z-index: 2; z-index: 2;
} }