mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 14:32:46 +00:00
feat: add dropdown to select preview port (#17)
This commit is contained in:
parent
5f06f508cd
commit
f55b4e5ab9
83
packages/bolt/app/components/workbench/PortDropdown.tsx
Normal file
83
packages/bolt/app/components/workbench/PortDropdown.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
@ -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<HTMLIFrameElement>(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 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 (
|
||||
<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">
|
||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||
<div
|
||||
@ -81,6 +101,16 @@ export const Preview = memo(() => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{previews.length > 1 && (
|
||||
<PortDropdown
|
||||
activePreviewIndex={activePreviewIndex}
|
||||
setActivePreviewIndex={setActivePreviewIndex}
|
||||
isDropdownOpen={isPortDropdownOpen}
|
||||
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
|
||||
setIsDropdownOpen={setIsPortDropdownOpen}
|
||||
previews={previews}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
||||
{activePreview ? (
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user