Merge pull request #1651 from xKevIsDev/improvements

feat: add expo app creation, enhance ui, and refactor code
This commit is contained in:
KevIsDev
2025-04-30 12:48:15 +01:00
committed by GitHub
64 changed files with 3248 additions and 910 deletions

View File

@@ -114,7 +114,7 @@ export const EditorPanel = memo(
</div>
)}
</PanelHeader>
<div className="h-full flex-1 overflow-hidden">
<div className="h-full flex-1 overflow-hidden modern-scrollbar">
<CodeMirrorEditor
theme={theme}
editable={!isStreaming && editorDocument !== undefined}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
import { useStore } from '@nanostores/react';
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
import { QRCode } from 'react-qrcode-logo';
interface ExpoQrModalProps {
open: boolean;
onClose: () => void;
}
export const ExpoQrModal: React.FC<ExpoQrModalProps> = ({ open, onClose }) => {
const expoUrl = useStore(expoUrlAtom);
return (
<DialogRoot open={open} onOpenChange={(v) => !v && onClose()}>
<Dialog
className="text-center !flex-col !mx-auto !text-center !max-w-md"
showCloseButton={true}
onClose={onClose}
>
<div className="border !border-bolt-elements-borderColor flex flex-col gap-5 justify-center items-center p-6 bg-bolt-elements-background-depth-2 rounded-md">
<div className="i-bolt:expo-brand h-10 w-full invert dark:invert-none"></div>
<DialogTitle className="text-bolt-elements-textTertiary text-lg font-semibold leading-6">
Preview on your own mobile device
</DialogTitle>
<DialogDescription className="bg-bolt-elements-background-depth-3 max-w-sm rounded-md p-1 border border-bolt-elements-borderColor">
Scan this QR code with the Expo Go app on your mobile device to open your project.
</DialogDescription>
<div className="my-6 flex flex-col items-center">
{expoUrl ? (
<QRCode
logoImage="/favicon.svg"
removeQrCodeBehindLogo={true}
logoPadding={3}
logoHeight={50}
logoWidth={50}
logoPaddingStyle="square"
style={{
borderRadius: 16,
padding: 2,
backgroundColor: '#8a5fff',
}}
value={expoUrl}
size={200}
/>
) : (
<div className="text-gray-500 text-center">No Expo URL detected.</div>
)}
</div>
</div>
</Dialog>
</DialogRoot>
);
};

View File

@@ -143,7 +143,7 @@ export const FileTree = memo(
};
return (
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
<div className={classNames('text-sm', className, 'overflow-y-auto modern-scrollbar')}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {

View File

@@ -1,5 +1,4 @@
import { memo, useEffect, useRef } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import type { PreviewInfo } from '~/lib/stores/previews';
interface PortDropdownProps {
@@ -48,9 +47,18 @@ export const PortDropdown = memo(
return (
<div className="relative z-port-dropdown" ref={dropdownRef}>
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
{/* Display the active port if available, otherwise show the plug icon */}
<button
className="flex items-center group-focus-within:text-bolt-elements-preview-addressBar-text bg-white group-focus-within:bg-bolt-elements-preview-addressBar-background dark:bg-bolt-elements-preview-addressBar-backgroundHover rounded-full px-2 py-1 gap-1.5"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span className="i-ph:plug text-base"></span>
{previews.length > 0 && activePreviewIndex >= 0 && activePreviewIndex < previews.length ? (
<span className="text-xs font-medium">{previews[activePreviewIndex].port}</span>
) : null}
</button>
{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="absolute left-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>

View File

@@ -4,6 +4,8 @@ import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
import { ScreenshotSelector } from './ScreenshotSelector';
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
type ResizeSide = 'left' | 'right' | null;
@@ -53,12 +55,10 @@ export const Preview = memo(() => {
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 [displayPath, setDisplayPath] = useState('/');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false);
@@ -86,39 +86,22 @@ export const Preview = memo(() => {
const [isLandscape, setIsLandscape] = useState(false);
const [showDeviceFrame, setShowDeviceFrame] = useState(true);
const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false);
const expoUrl = useStore(expoUrlAtom);
const [isExpoQrModalOpen, setIsExpoQrModalOpen] = useState(false);
useEffect(() => {
if (!activePreview) {
setUrl('');
setIframeUrl(undefined);
setDisplayPath('/');
return;
}
const { baseUrl } = activePreview;
setUrl(baseUrl);
setIframeUrl(baseUrl);
setDisplayPath('/');
}, [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;
@@ -565,6 +548,12 @@ export const Preview = memo(() => {
}
};
const openInNewTab = () => {
if (activePreview?.baseUrl) {
window.open(activePreview?.baseUrl, '_blank');
}
};
// Function to get the correct frame padding based on orientation
const getFramePadding = useCallback(() => {
if (!selectedWindowSize) {
@@ -630,10 +619,7 @@ export const Preview = memo(() => {
}, [showDeviceFrameInPreview]);
return (
<div
ref={containerRef}
className={`w-full h-full flex flex-col relative ${isPreviewOnly ? 'fixed inset-0 z-50 bg-white' : ''}`}
>
<div ref={containerRef} className={`w-full h-full flex flex-col relative`}>
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
@@ -647,50 +633,60 @@ export const Preview = memo(() => {
/>
</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">
<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-1 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">
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
<input
title="URL"
title="URL Path"
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={url}
value={displayPath}
onChange={(event) => {
setUrl(event.target.value);
setDisplayPath(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && validateUrl(url)) {
setIframeUrl(url);
if (event.key === 'Enter' && activePreview) {
let targetPath = displayPath.trim();
if (!targetPath.startsWith('/')) {
targetPath = '/' + targetPath;
}
const fullUrl = activePreview.baseUrl + targetPath;
setIframeUrl(fullUrl);
setDisplayPath(targetPath);
if (inputRef.current) {
inputRef.current.blur();
}
}
}}
disabled={!activePreview}
/>
</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'}
/>
{expoUrl && <IconButton icon="i-ph:qr-code" onClick={() => setIsExpoQrModalOpen(true)} title="Show QR" />}
<ExpoQrModal open={isExpoQrModalOpen} onClose={() => setIsExpoQrModalOpen(false)} />
{isDeviceModeOn && (
<>
<IconButton
icon="i-ph:rotate-right"
icon="i-ph:device-rotate"
onClick={() => setIsLandscape(!isLandscape)}
title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'}
/>
@@ -702,60 +698,17 @@ export const Preview = memo(() => {
</>
)}
<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'}
/>
{/* Simple preview button */}
<IconButton
icon="i-ph:browser"
onClick={() => {
if (!activePreview?.baseUrl) {
console.warn('[Preview] No active preview available');
return;
}
const match = activePreview.baseUrl.match(
/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/,
);
if (!match) {
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
return;
}
const previewId = match[1];
const previewUrl = `/webcontainer/preview/${previewId}`;
// Open in a new window with simple parameters
window.open(
previewUrl,
`preview-${previewId}`,
'width=1280,height=720,menubar=no,toolbar=no,location=no,status=no,resizable=yes',
);
}}
title="Open Preview in New Window"
/>
<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"
icon="i-ph:list"
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
className="ml-1"
title="Select Window Size"
title="New Window Options"
/>
{isWindowSizeDropdownOpen && (
@@ -764,11 +717,51 @@ export const Preview = memo(() => {
<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>
<span className="text-sm font-medium text-[#111827] dark:text-gray-300">Window Options</span>
</div>
<div className="flex flex-col gap-2">
<button
className={`flex w-full justify-between items-center text-start bg-transparent text-xs text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary`}
onClick={() => {
openInNewTab();
}}
>
<span>Open in new tab</span>
<div className="i-ph:arrow-square-out h-5 w-4" />
</button>
<button
className={`flex w-full justify-between items-center text-start bg-transparent text-xs text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary`}
onClick={() => {
if (!activePreview?.baseUrl) {
console.warn('[Preview] No active preview available');
return;
}
const match = activePreview.baseUrl.match(
/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/,
);
if (!match) {
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
return;
}
const previewId = match[1];
const previewUrl = `/webcontainer/preview/${previewId}`;
// Open in a new window with simple parameters
window.open(
previewUrl,
`preview-${previewId}`,
'width=1280,height=720,menubar=no,toolbar=no,location=no,status=no,resizable=yes',
);
}}
>
<span>Open in new window</span>
<div className="i-ph:browser h-5 w-4" />
</button>
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400">Show Device Frame</span>
<span className="text-xs text-bolt-elements-textTertiary">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'
@@ -786,7 +779,7 @@ export const Preview = memo(() => {
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400">Landscape Mode</span>
<span className="text-xs text-bolt-elements-textTertiary">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'
@@ -959,7 +952,7 @@ export const Preview = memo(() => {
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"
allow="geolocation; ch-ua-full-version-list; cross-origin-isolated; screen-wake-lock; publickey-credentials-get; shared-storage-select-url; ch-ua-arch; bluetooth; compute-pressure; ch-prefers-reduced-transparency; deferred-fetch; usb; ch-save-data; publickey-credentials-create; shared-storage; deferred-fetch-minimal; run-ad-auction; ch-ua-form-factors; ch-downlink; otp-credentials; payment; ch-ua; ch-ua-model; ch-ect; autoplay; camera; private-state-token-issuance; accelerometer; ch-ua-platform-version; idle-detection; private-aggregation; interest-cohort; ch-viewport-height; local-fonts; ch-ua-platform; midi; ch-ua-full-version; xr-spatial-tracking; clipboard-read; gamepad; display-capture; keyboard-map; join-ad-interest-group; ch-width; ch-prefers-reduced-motion; browsing-topics; encrypted-media; gyroscope; serial; ch-rtt; ch-ua-mobile; window-management; unload; ch-dpr; ch-prefers-color-scheme; ch-ua-wow64; attribution-reporting; fullscreen; identity-credentials-get; private-state-token-redemption; hid; ch-ua-bitness; storage-access; sync-xhr; ch-device-memory; ch-viewport-width; picture-in-picture; magnetometer; clipboard-write; microphone"
/>
)}
<ScreenshotSelector

View File

@@ -25,6 +25,7 @@ import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { usePreviewStore } from '~/lib/stores/previews';
interface WorkspaceProps {
chatStarted?: boolean;
@@ -323,9 +324,16 @@ export const Workbench = memo(
}, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {

View File

@@ -150,7 +150,7 @@ export const TerminalTabs = memo(() => {
<Terminal
key={index}
id={`terminal_${index}`}
className={classNames('h-full overflow-hidden', {
className={classNames('h-full overflow-hidden modern-scrollbar-invert', {
hidden: !isActive,
})}
ref={(ref) => {
@@ -166,7 +166,7 @@ export const TerminalTabs = memo(() => {
<Terminal
key={index}
id={`terminal_${index}`}
className={classNames('h-full overflow-hidden', {
className={classNames('modern-scrollbar h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {