bolt.diy/app/components/workbench/Preview.tsx
Stijnus 1c561a0615
feat: bolt dyi preview final (#1569)
* V1

## [Unreleased] - 2025-03-28

###  Fixed
- Fixed deployment errors on Cloudflare Pages caused by:
  - Missing or outdated `compatibility_date` and `compatibility_flags` in `wrangler.toml`
  - Use of Node.js built-ins (`crypto`, `stream`) in functions without proper polyfilling
  - Invalid Wrangler CLI options (`--log-level`) used during deployment
  - Type error when importing the Remix server build

### 🛠 Changed
- `wrangler.toml` updated:
  ```toml
  name = "bolt"
  compatibility_date = "2025-03-28"
  compatibility_flags = ["nodejs_compat"]
  pages_build_output_dir = "./build/client"
  send_metrics = false
  ```
- `functions/[[path]].ts` updated:
  ```ts
  import type { ServerBuild } from '@remix-run/cloudflare';
  import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
  import * as serverBuild from '../build/server';

  export const onRequest = createPagesFunctionHandler({
    build: serverBuild as unknown as ServerBuild,
  });
  ```

### 🚀 Deployment
- Successful deployment to:
  - Preview: https://979e2ca9.bolt-55b.pages.dev
  - Production: https://main.bolt-55b.pages.dev

* V1

## [Unreleased] - 2025-03-28

###  Fixed
- Fixed deployment errors on Cloudflare Pages caused by:
  - Missing or outdated `compatibility_date` and `compatibility_flags` in `wrangler.toml`
  - Use of Node.js built-ins (`crypto`, `stream`) in functions without proper polyfilling
  - Invalid Wrangler CLI options (`--log-level`) used during deployment
  - Type error when importing the Remix server build

### 🛠 Changed
- `wrangler.toml` updated:
  ```toml
  name = "bolt"
  compatibility_date = "2025-03-28"
  compatibility_flags = ["nodejs_compat"]
  pages_build_output_dir = "./build/client"
  send_metrics = false
  ```
- `functions/[[path]].ts` updated:
  ```ts
  import type { ServerBuild } from '@remix-run/cloudflare';
  import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
  import * as serverBuild from '../build/server';

  export const onRequest = createPagesFunctionHandler({
    build: serverBuild as unknown as ServerBuild,
  });
  ```

### 🚀 Deployment
- Successful deployment to:
  - Preview: https://979e2ca9.bolt-55b.pages.dev
  - Production: https://main.bolt-55b.pages.dev

* feat: small bugfix

* Update Preview.tsx
2025-03-29 20:57:41 +01:00

974 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useStore } from '@nanostores/react';
import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
import { ScreenshotSelector } from './ScreenshotSelector';
type ResizeSide = 'left' | 'right' | null;
interface WindowSize {
name: string;
width: number;
height: number;
icon: string;
hasFrame?: boolean;
frameType?: 'mobile' | 'tablet' | 'laptop' | 'desktop';
}
const WINDOW_SIZES: WindowSize[] = [
{ name: 'iPhone SE', width: 375, height: 667, icon: 'i-ph:device-mobile', hasFrame: true, frameType: 'mobile' },
{ name: 'iPhone 12/13', width: 390, height: 844, icon: 'i-ph:device-mobile', hasFrame: true, frameType: 'mobile' },
{
name: 'iPhone 12/13 Pro Max',
width: 428,
height: 926,
icon: 'i-ph:device-mobile',
hasFrame: true,
frameType: 'mobile',
},
{ name: 'iPad Mini', width: 768, height: 1024, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{ name: 'iPad Air', width: 820, height: 1180, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{ name: 'iPad Pro 11"', width: 834, height: 1194, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{
name: 'iPad Pro 12.9"',
width: 1024,
height: 1366,
icon: 'i-ph:device-tablet',
hasFrame: true,
frameType: 'tablet',
},
{ name: 'Small Laptop', width: 1280, height: 800, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Large Laptop', width: 1440, height: 900, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
{ name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
];
export const Preview = memo(() => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPreviewOnly, setIsPreviewOnly] = useState(false);
const hasSelectedPreview = useRef(false);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [url, setUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
// Use percentage for width
const [widthPercent, setWidthPercent] = useState<number>(37.5);
const [currentWidth, setCurrentWidth] = useState<number>(0);
const resizingState = useRef({
isResizing: false,
side: null as ResizeSide,
startX: 0,
startWidthPercent: 37.5,
windowWidth: window.innerWidth,
pointerId: null as number | null,
});
// Reduce scaling factor to make resizing less sensitive
const SCALING_FACTOR = 1;
const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false);
const [selectedWindowSize, setSelectedWindowSize] = useState<WindowSize>(WINDOW_SIZES[0]);
const [isLandscape, setIsLandscape] = useState(false);
const [showDeviceFrame, setShowDeviceFrame] = useState(true);
const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false);
useEffect(() => {
if (!activePreview) {
setUrl('');
setIframeUrl(undefined);
return;
}
const { baseUrl } = activePreview;
setUrl(baseUrl);
setIframeUrl(baseUrl);
}, [activePreview]);
const validateUrl = useCallback(
(value: string) => {
if (!activePreview) {
return false;
}
const { baseUrl } = activePreview;
if (value === baseUrl) {
return true;
} else if (value.startsWith(baseUrl)) {
return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
}
return false;
},
[activePreview],
);
const findMinPortIndex = useCallback(
(minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
return preview.port < array[minIndex].port ? index : minIndex;
},
[],
);
useEffect(() => {
if (previews.length > 1 && !hasSelectedPreview.current) {
const minPortIndex = previews.reduce(findMinPortIndex, 0);
setActivePreviewIndex(minPortIndex);
}
}, [previews, findMinPortIndex]);
const reloadPreview = () => {
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src;
}
};
const toggleFullscreen = async () => {
if (!isFullscreen && containerRef.current) {
await containerRef.current.requestFullscreen();
} else if (document.fullscreenElement) {
await document.exitFullscreen();
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleDeviceMode = () => {
setIsDeviceModeOn((prev) => !prev);
};
const startResizing = (e: React.PointerEvent, side: ResizeSide) => {
if (!isDeviceModeOn) {
return;
}
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
resizingState.current = {
isResizing: true,
side,
startX: e.clientX,
startWidthPercent: widthPercent,
windowWidth: window.innerWidth,
pointerId: e.pointerId,
};
};
const ResizeHandle = ({ side }: { side: ResizeSide }) => {
if (!side) {
return null;
}
return (
<div
className={`resize-handle-${side}`}
onPointerDown={(e) => startResizing(e, side)}
style={{
position: 'absolute',
top: 0,
...(side === 'left' ? { left: 0, marginLeft: '-7px' } : { right: 0, marginRight: '-7px' }),
width: '15px',
height: '100%',
cursor: 'ew-resize',
background: 'var(--bolt-elements-background-depth-4, rgba(0,0,0,.3))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
touchAction: 'none',
zIndex: 10,
}}
onMouseOver={(e) =>
(e.currentTarget.style.background = 'var(--bolt-elements-background-depth-4, rgba(0,0,0,.3))')
}
onMouseOut={(e) =>
(e.currentTarget.style.background = 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))')
}
title="Drag to resize width"
>
<GripIcon />
</div>
);
};
useEffect(() => {
// Skip if not in device mode
if (!isDeviceModeOn) {
return;
}
const handlePointerMove = (e: PointerEvent) => {
const state = resizingState.current;
if (!state.isResizing || e.pointerId !== state.pointerId) {
return;
}
const dx = e.clientX - state.startX;
const dxPercent = (dx / state.windowWidth) * 100 * SCALING_FACTOR;
let newWidthPercent = state.startWidthPercent;
if (state.side === 'right') {
newWidthPercent = state.startWidthPercent + dxPercent;
} else if (state.side === 'left') {
newWidthPercent = state.startWidthPercent - dxPercent;
}
// Limit width percentage between 10% and 90%
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
// Force a synchronous update to ensure the UI reflects the change immediately
setWidthPercent(newWidthPercent);
// Calculate and update the actual pixel width
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
const newWidth = Math.round((containerWidth * newWidthPercent) / 100);
setCurrentWidth(newWidth);
// Apply the width directly to the container for immediate feedback
const previewContainer = containerRef.current.querySelector('div[style*="width"]');
if (previewContainer) {
(previewContainer as HTMLElement).style.width = `${newWidthPercent}%`;
}
}
};
const handlePointerUp = (e: PointerEvent) => {
const state = resizingState.current;
if (!state.isResizing || e.pointerId !== state.pointerId) {
return;
}
// Find all resize handles
const handles = document.querySelectorAll('.resize-handle-left, .resize-handle-right');
// Release pointer capture from any handle that has it
handles.forEach((handle) => {
if ((handle as HTMLElement).hasPointerCapture?.(e.pointerId)) {
(handle as HTMLElement).releasePointerCapture(e.pointerId);
}
});
// Reset state
resizingState.current = {
...resizingState.current,
isResizing: false,
side: null,
pointerId: null,
};
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
// Add event listeners
document.addEventListener('pointermove', handlePointerMove, { passive: false });
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
// Define cleanup function
function cleanupResizeListeners() {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
// Release any lingering pointer captures
if (resizingState.current.pointerId !== null) {
const handles = document.querySelectorAll('.resize-handle-left, .resize-handle-right');
handles.forEach((handle) => {
if ((handle as HTMLElement).hasPointerCapture?.(resizingState.current.pointerId!)) {
(handle as HTMLElement).releasePointerCapture(resizingState.current.pointerId!);
}
});
// Reset state
resizingState.current = {
...resizingState.current,
isResizing: false,
side: null,
pointerId: null,
};
document.body.style.userSelect = '';
document.body.style.cursor = '';
}
}
// Return the cleanup function
// eslint-disable-next-line consistent-return
return cleanupResizeListeners;
}, [isDeviceModeOn, SCALING_FACTOR]);
useEffect(() => {
const handleWindowResize = () => {
// Update the window width in the resizing state
resizingState.current.windowWidth = window.innerWidth;
// Update the current width in pixels
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
};
window.addEventListener('resize', handleWindowResize);
// Initial calculation of current width
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, [isDeviceModeOn, widthPercent]);
// Update current width when device mode is toggled
useEffect(() => {
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
}, [isDeviceModeOn]);
const GripIcon = () => (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
pointerEvents: 'none',
}}
>
<div
style={{
color: 'var(--bolt-elements-textSecondary, rgba(0,0,0,0.5))',
fontSize: '10px',
lineHeight: '5px',
userSelect: 'none',
marginLeft: '1px',
}}
>
</div>
</div>
);
const openInNewWindow = (size: WindowSize) => {
if (activePreview?.baseUrl) {
const match = activePreview.baseUrl.match(/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/);
if (match) {
const previewId = match[1];
const previewUrl = `/webcontainer/preview/${previewId}`;
// Adjust dimensions for landscape mode if applicable
let width = size.width;
let height = size.height;
if (isLandscape && (size.frameType === 'mobile' || size.frameType === 'tablet')) {
// Swap width and height for landscape mode
width = size.height;
height = size.width;
}
// Create a window with device frame if enabled
if (showDeviceFrame && size.hasFrame) {
// Calculate frame dimensions
const frameWidth = size.frameType === 'mobile' ? (isLandscape ? 120 : 40) : 60; // Width padding on each side
const frameHeight = size.frameType === 'mobile' ? (isLandscape ? 80 : 80) : isLandscape ? 60 : 100; // Height padding on top and bottom
// Create a window with the correct dimensions first
const newWindow = window.open(
'',
'_blank',
`width=${width + frameWidth},height=${height + frameHeight + 40},menubar=no,toolbar=no,location=no,status=no`,
);
if (!newWindow) {
console.error('Failed to open new window');
return;
}
// Create the HTML content for the frame
const frameColor = getFrameColor();
const frameRadius = size.frameType === 'mobile' ? '36px' : '20px';
const framePadding =
size.frameType === 'mobile'
? isLandscape
? '40px 60px'
: '40px 20px'
: isLandscape
? '30px 50px'
: '50px 30px';
// Position notch and home button based on orientation
const notchTop = isLandscape ? '50%' : '20px';
const notchLeft = isLandscape ? '30px' : '50%';
const notchTransform = isLandscape ? 'translateY(-50%)' : 'translateX(-50%)';
const notchWidth = isLandscape ? '8px' : size.frameType === 'mobile' ? '60px' : '80px';
const notchHeight = isLandscape ? (size.frameType === 'mobile' ? '60px' : '80px') : '8px';
const homeBottom = isLandscape ? '50%' : '15px';
const homeRight = isLandscape ? '30px' : '50%';
const homeTransform = isLandscape ? 'translateY(50%)' : 'translateX(50%)';
const homeWidth = isLandscape ? '4px' : '40px';
const homeHeight = isLandscape ? '40px' : '4px';
// Create HTML content for the wrapper page
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${size.name} Preview</title>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f0f0f0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.device-container {
position: relative;
}
.device-name {
position: absolute;
top: -30px;
left: 0;
right: 0;
text-align: center;
font-size: 14px;
color: #333;
}
.device-frame {
position: relative;
border-radius: ${frameRadius};
background: ${frameColor};
padding: ${framePadding};
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
/* Notch */
.device-frame:before {
content: '';
position: absolute;
top: ${notchTop};
left: ${notchLeft};
transform: ${notchTransform};
width: ${notchWidth};
height: ${notchHeight};
background: #333;
border-radius: 4px;
z-index: 2;
}
/* Home button */
.device-frame:after {
content: '';
position: absolute;
bottom: ${homeBottom};
right: ${homeRight};
transform: ${homeTransform};
width: ${homeWidth};
height: ${homeHeight};
background: #333;
border-radius: 50%;
z-index: 2;
}
iframe {
border: none;
width: ${width}px;
height: ${height}px;
background: white;
display: block;
}
</style>
</head>
<body>
<div class="device-container">
<div class="device-name">${size.name} ${isLandscape ? '(Landscape)' : '(Portrait)'}</div>
<div class="device-frame">
<iframe src="${previewUrl}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin" allow="cross-origin-isolated"></iframe>
</div>
</div>
</body>
</html>
`;
// Write the HTML content to the new window
newWindow.document.open();
newWindow.document.write(htmlContent);
newWindow.document.close();
} else {
// Standard window without frame
const newWindow = window.open(
previewUrl,
'_blank',
`width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no`,
);
if (newWindow) {
newWindow.focus();
}
}
} else {
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
}
}
};
// Function to get the correct frame padding based on orientation
const getFramePadding = useCallback(() => {
if (!selectedWindowSize) {
return '40px 20px';
}
const isMobile = selectedWindowSize.frameType === 'mobile';
if (isLandscape) {
// Increase horizontal padding in landscape mode to ensure full device frame is visible
return isMobile ? '40px 60px' : '30px 50px';
}
return isMobile ? '40px 20px' : '50px 30px';
}, [isLandscape, selectedWindowSize]);
// Function to get the scale factor for the device frame
const getDeviceScale = useCallback(() => {
// Always return 1 to ensure the device frame is shown at its exact size
return 1;
}, [isLandscape, selectedWindowSize, widthPercent]);
// Update the device scale when needed
useEffect(() => {
/*
* Intentionally disabled - we want to maintain scale of 1
* No dynamic scaling to ensure device frame matches external window exactly
*/
return () => {};
}, [isDeviceModeOn, showDeviceFrameInPreview, getDeviceScale, isLandscape, selectedWindowSize]);
// Function to get the frame color based on dark mode
const getFrameColor = useCallback(() => {
// Check if the document has a dark class or data-theme="dark"
const isDarkMode =
document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark' ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
// Return a darker color for light mode, lighter color for dark mode
return isDarkMode ? '#555' : '#111';
}, []);
// Effect to handle color scheme changes
useEffect(() => {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleColorSchemeChange = () => {
// Force a re-render when color scheme changes
if (showDeviceFrameInPreview) {
setShowDeviceFrameInPreview(true);
}
};
darkModeMediaQuery.addEventListener('change', handleColorSchemeChange);
return () => {
darkModeMediaQuery.removeEventListener('change', handleColorSchemeChange);
};
}, [showDeviceFrameInPreview]);
return (
<div
ref={containerRef}
className={`w-full h-full flex flex-col relative ${isPreviewOnly ? 'fixed inset-0 z-50 bg-white' : ''}`}
>
{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-2">
<div className="flex items-center gap-2">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
</div>
<div className="flex-grow flex items-center gap-1 bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive">
<input
title="URL"
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={url}
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && validateUrl(url)) {
setIframeUrl(url);
if (inputRef.current) {
inputRef.current.blur();
}
}
}}
/>
</div>
<div className="flex items-center gap-2">
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
<IconButton
icon="i-ph:devices"
onClick={toggleDeviceMode}
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
/>
{isDeviceModeOn && (
<>
<IconButton
icon="i-ph:rotate-right"
onClick={() => setIsLandscape(!isLandscape)}
title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'}
/>
<IconButton
icon={showDeviceFrameInPreview ? 'i-ph:device-mobile' : 'i-ph:device-mobile-slash'}
onClick={() => setShowDeviceFrameInPreview(!showDeviceFrameInPreview)}
title={showDeviceFrameInPreview ? 'Hide Device Frame' : 'Show Device Frame'}
/>
</>
)}
<IconButton
icon="i-ph:layout-light"
onClick={() => setIsPreviewOnly(!isPreviewOnly)}
title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'}
/>
<IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
/>
<div className="flex items-center relative">
<IconButton
icon="i-ph:arrow-square-out"
onClick={() => openInNewWindow(selectedWindowSize)}
title={`Open Preview in ${selectedWindowSize.name} Window`}
/>
<IconButton
icon="i-ph:caret-down"
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
className="ml-1"
title="Select Window Size"
/>
{isWindowSizeDropdownOpen && (
<>
<div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] max-h-[400px] overflow-y-auto bg-white dark:bg-black rounded-xl shadow-2xl border border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)] overflow-hidden">
<div className="p-3 border-b border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[#111827] dark:text-gray-300">Device Options</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400">Show Device Frame</span>
<button
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
showDeviceFrame ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
} relative`}
onClick={(e) => {
e.stopPropagation();
setShowDeviceFrame(!showDeviceFrame);
}}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
showDeviceFrame ? 'transform translate-x-5' : ''
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400">Landscape Mode</span>
<button
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
isLandscape ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
} relative`}
onClick={(e) => {
e.stopPropagation();
setIsLandscape(!isLandscape);
}}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
isLandscape ? 'transform translate-x-5' : ''
}`}
/>
</button>
</div>
</div>
</div>
{WINDOW_SIZES.map((size) => (
<button
key={size.name}
className="w-full px-4 py-3.5 text-left text-[#111827] dark:text-gray-300 text-sm whitespace-nowrap flex items-center gap-3 group hover:bg-[#F5EEFF] dark:hover:bg-gray-900 bg-white dark:bg-black"
onClick={() => {
setSelectedWindowSize(size);
setIsWindowSizeDropdownOpen(false);
openInNewWindow(size);
}}
>
<div
className={`${size.icon} w-5 h-5 text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200`}
/>
<div className="flex-grow flex flex-col">
<span className="font-medium group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{size.name}
</span>
<span className="text-xs text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{isLandscape && (size.frameType === 'mobile' || size.frameType === 'tablet')
? `${size.height} × ${size.width}`
: `${size.width} × ${size.height}`}
{size.hasFrame && showDeviceFrame ? ' (with frame)' : ''}
</span>
</div>
{selectedWindowSize.name === size.name && (
<div className="text-[#6D28D9] dark:text-[#6D28D9]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
<div
style={{
width: isDeviceModeOn ? (showDeviceFrameInPreview ? '100%' : `${widthPercent}%`) : '100%',
height: '100%',
overflow: 'auto',
background: 'var(--bolt-elements-background-depth-1)',
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{activePreview ? (
<>
{isDeviceModeOn && showDeviceFrameInPreview ? (
<div
className="device-wrapper"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: '0',
overflow: 'auto',
transition: 'all 0.3s ease',
position: 'relative',
}}
>
<div
className="device-frame-container"
style={{
position: 'relative',
borderRadius: selectedWindowSize.frameType === 'mobile' ? '36px' : '20px',
background: getFrameColor(),
padding: getFramePadding(),
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
overflow: 'hidden',
transform: 'scale(1)',
transformOrigin: 'center center',
transition: 'all 0.3s ease',
margin: '40px',
width: isLandscape
? `${selectedWindowSize.height + (selectedWindowSize.frameType === 'mobile' ? 120 : 60)}px`
: `${selectedWindowSize.width + (selectedWindowSize.frameType === 'mobile' ? 40 : 60)}px`,
height: isLandscape
? `${selectedWindowSize.width + (selectedWindowSize.frameType === 'mobile' ? 80 : 60)}px`
: `${selectedWindowSize.height + (selectedWindowSize.frameType === 'mobile' ? 80 : 100)}px`,
}}
>
{/* Notch - positioned based on orientation */}
<div
style={{
position: 'absolute',
top: isLandscape ? '50%' : '20px',
left: isLandscape ? '30px' : '50%',
transform: isLandscape ? 'translateY(-50%)' : 'translateX(-50%)',
width: isLandscape ? '8px' : selectedWindowSize.frameType === 'mobile' ? '60px' : '80px',
height: isLandscape ? (selectedWindowSize.frameType === 'mobile' ? '60px' : '80px') : '8px',
background: '#333',
borderRadius: '4px',
zIndex: 2,
}}
/>
{/* Home button - positioned based on orientation */}
<div
style={{
position: 'absolute',
bottom: isLandscape ? '50%' : '15px',
right: isLandscape ? '30px' : '50%',
transform: isLandscape ? 'translateY(50%)' : 'translateX(50%)',
width: isLandscape ? '4px' : '40px',
height: isLandscape ? '40px' : '4px',
background: '#333',
borderRadius: '50%',
zIndex: 2,
}}
/>
<iframe
ref={iframeRef}
title="preview"
style={{
border: 'none',
width: isLandscape ? `${selectedWindowSize.height}px` : `${selectedWindowSize.width}px`,
height: isLandscape ? `${selectedWindowSize.width}px` : `${selectedWindowSize.height}px`,
background: 'white',
display: 'block',
}}
src={iframeUrl}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
</div>
</div>
) : (
<iframe
ref={iframeRef}
title="preview"
className="border-none w-full h-full bg-bolt-elements-background-depth-1"
src={iframeUrl}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
)}
<ScreenshotSelector
isSelectionMode={isSelectionMode}
setIsSelectionMode={setIsSelectionMode}
containerRef={iframeRef}
/>
</>
) : (
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
No preview available
</div>
)}
{isDeviceModeOn && !showDeviceFrameInPreview && (
<>
{/* Width indicator */}
<div
style={{
position: 'absolute',
top: '-25px',
left: '50%',
transform: 'translateX(-50%)',
background: 'var(--bolt-elements-background-depth-3, rgba(0,0,0,0.7))',
color: 'var(--bolt-elements-textPrimary, white)',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
pointerEvents: 'none',
opacity: resizingState.current.isResizing ? 1 : 0,
transition: 'opacity 0.3s',
}}
>
{currentWidth}px
</div>
<ResizeHandle side="left" />
<ResizeHandle side="right" />
</>
)}
</div>
</div>
</div>
);
});