mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-09 14:30:51 +00:00
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:
parent
cbc22cdbdb
commit
9039653ae0
@ -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>
|
||||
|
@ -249,6 +249,8 @@ export const ChatImpl = memo(
|
||||
});
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
|
||||
console.log('messages', messages);
|
||||
|
||||
const scrollTextArea = () => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
},
|
||||
|
43
app/components/workbench/ExpoQrModal.tsx
Normal file
43
app/components/workbench/ExpoQrModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
@ -38,6 +38,8 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
|
||||
|
||||
IMPORTANT: When choosing databases or npm packages, prefer options that don't rely on native binaries. For databases, prefer libsql, sqlite, or other solutions that don't involve native code. WebContainer CANNOT execute arbitrary native binaries.
|
||||
|
||||
IMPORTANT You must never use the "bundled" type for artifacts.
|
||||
|
||||
Available shell commands:
|
||||
File Operations:
|
||||
- cat: Display file contents
|
||||
@ -481,6 +483,7 @@ Here are some examples of correct usage of artifacts:
|
||||
The following instructions guide how you should handle mobile app development using Expo and React Native.
|
||||
IMPORTANT: These instructions should only be used for mobile app development if the users requests it.
|
||||
IMPORTANT: Make sure to follow the instructions below to ensure a successful mobile app development process, The project structure must follow what has been provided.
|
||||
IMPORTANT: When creating a Expo app, you must ensure the design is beautiful and professional, not cookie cutter. The app should be populted with content and never just be a blank example.
|
||||
|
||||
<core_requirements>
|
||||
- Version: 2025
|
||||
|
4
app/stores/qrCodeStore.ts
Normal file
4
app/stores/qrCodeStore.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const qrCodeAtom = atom<string | null>(null);
|
||||
export const expoUrlAtom = atom<string | null>(null);
|
@ -2,6 +2,7 @@ import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
|
||||
import type { ITerminal } from '~/types/terminal';
|
||||
import { withResolvers } from './promises';
|
||||
import { atom } from 'nanostores';
|
||||
import { expoUrlAtom } from '~/stores/qrCodeStore';
|
||||
|
||||
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
@ -80,13 +81,96 @@ export class BoltShell {
|
||||
this.#webcontainer = webcontainer;
|
||||
this.#terminal = terminal;
|
||||
|
||||
const { process, output } = await this.newBoltShellProcess(webcontainer, terminal);
|
||||
// Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection
|
||||
const { process, commandStream, expoUrlStream } = await this.newBoltShellProcess(webcontainer, terminal);
|
||||
this.#process = process;
|
||||
this.#outputStream = output.getReader();
|
||||
this.#outputStream = commandStream.getReader();
|
||||
|
||||
// Start background Expo URL watcher immediately
|
||||
this._watchExpoUrlInBackground(expoUrlStream);
|
||||
|
||||
await this.waitTillOscCode('interactive');
|
||||
this.#initialized?.();
|
||||
}
|
||||
|
||||
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
|
||||
terminal: {
|
||||
cols: terminal.cols ?? 80,
|
||||
rows: terminal.rows ?? 15,
|
||||
},
|
||||
});
|
||||
|
||||
const input = process.input.getWriter();
|
||||
this.#shellInputStream = input;
|
||||
|
||||
// Tee the output so we can have three independent readers
|
||||
const [streamA, streamB] = process.output.tee();
|
||||
const [streamC, streamD] = streamB.tee();
|
||||
|
||||
const jshReady = withResolvers<void>();
|
||||
let isInteractive = false;
|
||||
streamA.pipeTo(
|
||||
new WritableStream({
|
||||
write(data) {
|
||||
if (!isInteractive) {
|
||||
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
|
||||
|
||||
if (osc === 'interactive') {
|
||||
isInteractive = true;
|
||||
jshReady.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
terminal.write(data);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (isInteractive) {
|
||||
input.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
await jshReady.promise;
|
||||
|
||||
// Return all streams for use in init
|
||||
return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD };
|
||||
}
|
||||
|
||||
// Dedicated background watcher for Expo URL
|
||||
private async _watchExpoUrlInBackground(stream: ReadableStream<string>) {
|
||||
const reader = stream.getReader();
|
||||
let buffer = '';
|
||||
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += value || '';
|
||||
|
||||
const expoUrlMatch = buffer.match(expoUrlRegex);
|
||||
|
||||
if (expoUrlMatch) {
|
||||
const cleanUrl = expoUrlMatch[1]
|
||||
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
|
||||
.replace(/[^\x20-\x7E]+$/g, '');
|
||||
expoUrlAtom.set(cleanUrl);
|
||||
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
|
||||
}
|
||||
|
||||
if (buffer.length > 2048) {
|
||||
buffer = buffer.slice(-2048);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return this.#terminal;
|
||||
}
|
||||
@ -138,65 +222,17 @@ export class BoltShell {
|
||||
return resp;
|
||||
}
|
||||
|
||||
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
|
||||
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
|
||||
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
|
||||
terminal: {
|
||||
cols: terminal.cols ?? 80,
|
||||
rows: terminal.rows ?? 15,
|
||||
},
|
||||
});
|
||||
|
||||
const input = process.input.getWriter();
|
||||
this.#shellInputStream = input;
|
||||
|
||||
const [internalOutput, terminalOutput] = process.output.tee();
|
||||
|
||||
const jshReady = withResolvers<void>();
|
||||
|
||||
let isInteractive = false;
|
||||
terminalOutput.pipeTo(
|
||||
new WritableStream({
|
||||
write(data) {
|
||||
if (!isInteractive) {
|
||||
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
|
||||
|
||||
if (osc === 'interactive') {
|
||||
// wait until we see the interactive OSC
|
||||
isInteractive = true;
|
||||
|
||||
jshReady.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
terminal.write(data);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.onData((data) => {
|
||||
// console.log('terminal onData', { data, isInteractive });
|
||||
|
||||
if (isInteractive) {
|
||||
input.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
await jshReady.promise;
|
||||
|
||||
return { process, output: internalOutput };
|
||||
}
|
||||
|
||||
async getCurrentExecutionResult(): Promise<ExecutionResult> {
|
||||
const { output, exitCode } = await this.waitTillOscCode('exit');
|
||||
return { output, exitCode };
|
||||
}
|
||||
|
||||
onQRCodeDetected?: (qrCode: string) => void;
|
||||
|
||||
async waitTillOscCode(waitCode: string) {
|
||||
let fullOutput = '';
|
||||
let exitCode: number = 0;
|
||||
let buffer = ''; // <-- Add a buffer to accumulate output
|
||||
|
||||
if (!this.#outputStream) {
|
||||
return { output: fullOutput, exitCode };
|
||||
@ -204,6 +240,9 @@ export class BoltShell {
|
||||
|
||||
const tappedStream = this.#outputStream;
|
||||
|
||||
// Regex for Expo URL
|
||||
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await tappedStream.read();
|
||||
|
||||
@ -213,6 +252,21 @@ export class BoltShell {
|
||||
|
||||
const text = value || '';
|
||||
fullOutput += text;
|
||||
buffer += text; // <-- Accumulate in buffer
|
||||
|
||||
// Extract Expo URL from buffer and set store
|
||||
const expoUrlMatch = buffer.match(expoUrlRegex);
|
||||
|
||||
if (expoUrlMatch) {
|
||||
// Remove any trailing ANSI escape codes or non-printable characters
|
||||
const cleanUrl = expoUrlMatch[1]
|
||||
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
|
||||
.replace(/[^\x20-\x7E]+$/g, '');
|
||||
expoUrlAtom.set(cleanUrl);
|
||||
|
||||
// Remove everything up to and including the URL from the buffer to avoid duplicate matches
|
||||
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
|
||||
}
|
||||
|
||||
// Check if command completion signal with exit code
|
||||
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
|
||||
|
1
icons/expo-brand.svg
Normal file
1
icons/expo-brand.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="114" height="32" viewBox="0 0 114 32"><path fill="#FFF" d="M14.805 10.334c.256-.377.535-.425.762-.425.227 0 .605.048.86.425 2.015 2.766 5.34 8.277 7.791 12.342 1.6 2.65 2.828 4.687 3.08 4.946.946.972 2.243.366 2.997-.737.742-1.086.948-1.849.948-2.663 0-.554-10.752-20.552-11.835-22.217C18.366.405 18.028 0 16.245 0H14.91c-1.777 0-2.034.404-3.075 2.005C10.753 3.67 0 23.668 0 24.222c0 .814.206 1.577.948 2.663.754 1.103 2.052 1.71 2.998.737.252-.26 1.48-2.295 3.08-4.946 2.451-4.065 5.765-9.576 7.78-12.342Zm23.913-8.566v24.55h14.96v-4.98h-9.799v-5.19h8.718v-4.98h-8.718v-4.42h9.799v-4.98h-14.96Zm34.922 24.55-6.208-8.908 5.789-8.312h-5.859l-2.859 4.068-2.825-4.068H55.75l5.789 8.347-6.172 8.873h5.858l3.243-4.664 3.243 4.664h5.928ZM86.115 8.747c-2.37 0-4.22.982-5.405 2.77v-2.42h-4.916V32h4.916v-8.102c1.186 1.789 3.034 2.771 5.405 2.771 4.429 0 7.95-4.033 7.95-8.979 0-4.945-3.521-8.943-7.95-8.943ZM85 21.934c-2.407 0-4.29-1.824-4.29-4.244 0-2.384 1.883-4.243 4.29-4.243 2.37 0 4.289 1.894 4.289 4.244 0 2.384-1.918 4.243-4.29 4.243Zm19.791-13.187c-5.056 0-8.892 3.858-8.892 8.979 0 5.12 3.835 8.943 8.892 8.943 5.021 0 8.892-3.823 8.892-8.943 0-5.121-3.871-8.979-8.892-8.979Zm0 4.735c2.301 0 4.08 1.789 4.08 4.244 0 2.384-1.779 4.208-4.08 4.208-2.337 0-4.08-1.824-4.08-4.208 0-2.455 1.743-4.244 4.08-4.244Z"></path></svg>
|
After Width: | Height: | Size: 1.3 KiB |
@ -136,6 +136,7 @@
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-toastify": "^10.0.6",
|
||||
"react-window": "^1.8.11",
|
||||
|
@ -287,6 +287,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^9.0.1
|
||||
version: 9.1.0(@types/react@18.3.20)(react@18.3.1)
|
||||
react-qr-code:
|
||||
specifier: ^2.0.15
|
||||
version: 2.0.15(react@18.3.1)
|
||||
react-resizable-panels:
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@ -6556,6 +6559,9 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qr.js@0.0.0:
|
||||
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
|
||||
|
||||
qs@6.13.0:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
@ -6657,6 +6663,11 @@ packages:
|
||||
'@types/react': '>=18'
|
||||
react: '>=18'
|
||||
|
||||
react-qr-code@2.0.15:
|
||||
resolution: {integrity: sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react-redux@7.2.9:
|
||||
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
|
||||
peerDependencies:
|
||||
@ -15554,6 +15565,8 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qr.js@0.0.0: {}
|
||||
|
||||
qs@6.13.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@ -15668,6 +15681,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-qr-code@2.0.15(react@18.3.1):
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
qr.js: 0.0.0
|
||||
react: 18.3.1
|
||||
|
||||
react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.0
|
||||
|
Loading…
Reference in New Issue
Block a user