feat: add Expo QR code generation and modal for mobile preview

Introduce Expo QR code functionality to allow users to preview their projects on mobile devices. Added a new QR code modal component, integrated it into the chat and preview components, and implemented Expo URL detection in the shell process. This enhances the mobile development workflow by providing a seamless way to test Expo projects directly on devices.

- Clean up and consolidate Preview icon buttons while removing redundant ones.
This commit is contained in:
KevIsDev
2025-04-17 13:03:41 +01:00
parent cbc22cdbdb
commit 9039653ae0
12 changed files with 238 additions and 106 deletions

View File

@@ -39,6 +39,9 @@ import type { ActionRunner } from '~/lib/runtime/action-runner';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
import { SupabaseConnection } from './SupabaseConnection';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
import { expoUrlAtom } from '~/stores/qrCodeStore';
import { useStore } from '@nanostores/react';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -130,6 +133,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const [transcript, setTranscript] = useState('');
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
const expoUrl = useStore(expoUrlAtom);
const [qrModalOpen, setQrModalOpen] = useState(false);
useEffect(() => {
if (expoUrl) {
setQrModalOpen(true);
}
}, [expoUrl]);
useEffect(() => {
if (data) {
const progressList = data.filter(
@@ -622,6 +634,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
) : null}
<SupabaseConnection />
<ExpoQrModal open={qrModalOpen} onClose={() => setQrModalOpen(false)} />
</div>
</div>
</div>

View File

@@ -249,6 +249,8 @@ export const ChatImpl = memo(
});
}, [messages, isLoading, parseMessages]);
console.log('messages', messages);
const scrollTextArea = () => {
const textarea = textareaRef.current;

View File

@@ -116,7 +116,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px]',
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px] focus:outline-none',
className,
)}
initial="closed"

View File

@@ -46,7 +46,7 @@ export const IconButton = memo(
<button
ref={ref}
className={classNames(
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed focus:outline-none',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
import { useStore } from '@nanostores/react';
import { expoUrlAtom } from '~/stores/qrCodeStore';
import QRCode from 'react-qr-code';
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"></div>
<DialogTitle className="text-white 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 ? (
<div className="bg-white p-1 flex flex-col rounded-md justify-center items-center ">
<QRCode value={expoUrl} size={180} />
</div>
) : (
<div className="text-gray-500 text-center">No Expo URL detected.</div>
)}
</div>
</div>
</Dialog>
</DialogRoot>
);
};

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 '~/stores/qrCodeStore';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
type ResizeSide = 'left' | 'right' | null;
@@ -53,7 +55,6 @@ 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];
@@ -86,6 +87,8 @@ 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) {
@@ -636,10 +639,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)} />
)}
@@ -693,6 +693,10 @@ export const Preview = memo(() => {
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
@@ -708,60 +712,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 && (
@@ -770,7 +731,7 @@ 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
@@ -782,6 +743,37 @@ export const Preview = memo(() => {
<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-bolt-elements-textTertiary">Show Device Frame</span>
<button