mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-11 07:21:10 +00:00
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.
450 lines
12 KiB
TypeScript
450 lines
12 KiB
TypeScript
import * as RadixDialog from '@radix-ui/react-dialog';
|
|
import { motion, type Variants } from 'framer-motion';
|
|
import React, { memo, type ReactNode, useState, useEffect } from 'react';
|
|
import { classNames } from '~/utils/classNames';
|
|
import { cubicEasingFn } from '~/utils/easings';
|
|
import { IconButton } from './IconButton';
|
|
import { Button } from './Button';
|
|
import { FixedSizeList } from 'react-window';
|
|
import { Checkbox } from './Checkbox';
|
|
import { Label } from './Label';
|
|
|
|
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
|
|
|
|
interface DialogButtonProps {
|
|
type: 'primary' | 'secondary' | 'danger';
|
|
children: ReactNode;
|
|
onClick?: (event: React.MouseEvent) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
|
|
return (
|
|
<button
|
|
className={classNames(
|
|
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors',
|
|
type === 'primary'
|
|
? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600'
|
|
: type === 'secondary'
|
|
? 'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
|
|
: 'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10',
|
|
)}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
|
|
return (
|
|
<RadixDialog.Title
|
|
className={classNames('text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-2', className)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</RadixDialog.Title>
|
|
);
|
|
});
|
|
|
|
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
|
|
return (
|
|
<RadixDialog.Description
|
|
className={classNames('text-sm text-bolt-elements-textSecondary mt-1', className)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</RadixDialog.Description>
|
|
);
|
|
});
|
|
|
|
const transition = {
|
|
duration: 0.15,
|
|
ease: cubicEasingFn,
|
|
};
|
|
|
|
export const dialogBackdropVariants = {
|
|
closed: {
|
|
opacity: 0,
|
|
transition,
|
|
},
|
|
open: {
|
|
opacity: 1,
|
|
transition,
|
|
},
|
|
} satisfies Variants;
|
|
|
|
export const dialogVariants = {
|
|
closed: {
|
|
x: '-50%',
|
|
y: '-40%',
|
|
scale: 0.96,
|
|
opacity: 0,
|
|
transition,
|
|
},
|
|
open: {
|
|
x: '-50%',
|
|
y: '-50%',
|
|
scale: 1,
|
|
opacity: 1,
|
|
transition,
|
|
},
|
|
} satisfies Variants;
|
|
|
|
interface DialogProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
showCloseButton?: boolean;
|
|
onClose?: () => void;
|
|
onBackdrop?: () => void;
|
|
}
|
|
|
|
export const Dialog = memo(({ children, className, showCloseButton = true, onClose, onBackdrop }: DialogProps) => {
|
|
return (
|
|
<RadixDialog.Portal>
|
|
<RadixDialog.Overlay asChild>
|
|
<motion.div
|
|
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
|
|
initial="closed"
|
|
animate="open"
|
|
exit="closed"
|
|
variants={dialogBackdropVariants}
|
|
onClick={onBackdrop}
|
|
/>
|
|
</RadixDialog.Overlay>
|
|
<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] focus:outline-none',
|
|
className,
|
|
)}
|
|
initial="closed"
|
|
animate="open"
|
|
exit="closed"
|
|
variants={dialogVariants}
|
|
>
|
|
<div className="flex flex-col">
|
|
{children}
|
|
{showCloseButton && (
|
|
<RadixDialog.Close asChild onClick={onClose}>
|
|
<IconButton
|
|
icon="i-ph:x"
|
|
className="absolute top-3 right-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
|
|
/>
|
|
</RadixDialog.Close>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</RadixDialog.Content>
|
|
</RadixDialog.Portal>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Props for the ConfirmationDialog component
|
|
*/
|
|
export interface ConfirmationDialogProps {
|
|
/**
|
|
* Whether the dialog is open
|
|
*/
|
|
isOpen: boolean;
|
|
|
|
/**
|
|
* Callback when the dialog is closed
|
|
*/
|
|
onClose: () => void;
|
|
|
|
/**
|
|
* Callback when the confirm button is clicked
|
|
*/
|
|
onConfirm: () => void;
|
|
|
|
/**
|
|
* The title of the dialog
|
|
*/
|
|
title: string;
|
|
|
|
/**
|
|
* The description of the dialog
|
|
*/
|
|
description: string;
|
|
|
|
/**
|
|
* The text for the confirm button
|
|
*/
|
|
confirmLabel?: string;
|
|
|
|
/**
|
|
* The text for the cancel button
|
|
*/
|
|
cancelLabel?: string;
|
|
|
|
/**
|
|
* The variant of the confirm button
|
|
*/
|
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
|
|
/**
|
|
* Whether the confirm button is in a loading state
|
|
*/
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
/**
|
|
* A reusable confirmation dialog component that uses the Dialog component
|
|
*/
|
|
export function ConfirmationDialog({
|
|
isOpen,
|
|
onClose,
|
|
title,
|
|
description,
|
|
confirmLabel = 'Confirm',
|
|
cancelLabel = 'Cancel',
|
|
variant = 'default',
|
|
isLoading = false,
|
|
onConfirm,
|
|
}: ConfirmationDialogProps) {
|
|
return (
|
|
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
|
<Dialog showCloseButton={false}>
|
|
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription className="mb-4">{description}</DialogDescription>
|
|
<div className="flex justify-end space-x-2">
|
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
|
{cancelLabel}
|
|
</Button>
|
|
<Button
|
|
variant={variant}
|
|
onClick={onConfirm}
|
|
disabled={isLoading}
|
|
className={
|
|
variant === 'destructive'
|
|
? 'bg-red-500 text-white hover:bg-red-600'
|
|
: 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent hover:bg-bolt-elements-button-primary-backgroundHover'
|
|
}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
|
{confirmLabel}
|
|
</>
|
|
) : (
|
|
confirmLabel
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</RadixDialog.Root>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type for selection item in SelectionDialog
|
|
*/
|
|
type SelectionItem = {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
};
|
|
|
|
/**
|
|
* Props for the SelectionDialog component
|
|
*/
|
|
export interface SelectionDialogProps {
|
|
/**
|
|
* The title of the dialog
|
|
*/
|
|
title: string;
|
|
|
|
/**
|
|
* The items to select from
|
|
*/
|
|
items: SelectionItem[];
|
|
|
|
/**
|
|
* Whether the dialog is open
|
|
*/
|
|
isOpen: boolean;
|
|
|
|
/**
|
|
* Callback when the dialog is closed
|
|
*/
|
|
onClose: () => void;
|
|
|
|
/**
|
|
* Callback when the confirm button is clicked with selected item IDs
|
|
*/
|
|
onConfirm: (selectedIds: string[]) => void;
|
|
|
|
/**
|
|
* The text for the confirm button
|
|
*/
|
|
confirmLabel?: string;
|
|
|
|
/**
|
|
* The maximum height of the selection list
|
|
*/
|
|
maxHeight?: string;
|
|
}
|
|
|
|
/**
|
|
* A reusable selection dialog component that uses the Dialog component
|
|
*/
|
|
export function SelectionDialog({
|
|
title,
|
|
items,
|
|
isOpen,
|
|
onClose,
|
|
onConfirm,
|
|
confirmLabel = 'Confirm',
|
|
maxHeight = '60vh',
|
|
}: SelectionDialogProps) {
|
|
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
|
const [selectAll, setSelectAll] = useState(false);
|
|
|
|
// Reset selected items when dialog opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setSelectedItems([]);
|
|
setSelectAll(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleToggleItem = (id: string) => {
|
|
setSelectedItems((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]));
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectedItems.length === items.length) {
|
|
setSelectedItems([]);
|
|
setSelectAll(false);
|
|
} else {
|
|
setSelectedItems(items.map((item) => item.id));
|
|
setSelectAll(true);
|
|
}
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
onConfirm(selectedItems);
|
|
onClose();
|
|
};
|
|
|
|
// Calculate the height for the virtualized list
|
|
const listHeight = Math.min(
|
|
items.length * 60,
|
|
parseInt(maxHeight.replace('vh', '')) * window.innerHeight * 0.01 - 40,
|
|
);
|
|
|
|
// Render each item in the virtualized list
|
|
const ItemRenderer = ({ index, style }: { index: number; style: React.CSSProperties }) => {
|
|
const item = items[index];
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className={classNames(
|
|
'flex items-start space-x-3 p-2 rounded-md transition-colors',
|
|
selectedItems.includes(item.id)
|
|
? 'bg-bolt-elements-item-backgroundAccent'
|
|
: 'bg-bolt-elements-bg-depth-2 hover:bg-bolt-elements-item-backgroundActive',
|
|
)}
|
|
style={{
|
|
...style,
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
<Checkbox
|
|
id={`item-${item.id}`}
|
|
checked={selectedItems.includes(item.id)}
|
|
onCheckedChange={() => handleToggleItem(item.id)}
|
|
/>
|
|
<div className="grid gap-1.5 leading-none">
|
|
<Label
|
|
htmlFor={`item-${item.id}`}
|
|
className={classNames(
|
|
'text-sm font-medium cursor-pointer',
|
|
selectedItems.includes(item.id)
|
|
? 'text-bolt-elements-item-contentAccent'
|
|
: 'text-bolt-elements-textPrimary',
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Label>
|
|
{item.description && <p className="text-xs text-bolt-elements-textSecondary">{item.description}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
|
<Dialog showCloseButton={false}>
|
|
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription className="mt-2 mb-4">
|
|
Select the items you want to include and click{' '}
|
|
<span className="text-bolt-elements-item-contentAccent font-medium">{confirmLabel}</span>.
|
|
</DialogDescription>
|
|
|
|
<div className="py-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="text-sm font-medium text-bolt-elements-textSecondary">
|
|
{selectedItems.length} of {items.length} selected
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleSelectAll}
|
|
className="text-xs h-8 px-2 text-bolt-elements-textPrimary hover:text-bolt-elements-item-contentAccent hover:bg-bolt-elements-item-backgroundAccent bg-bolt-elements-bg-depth-2 dark:bg-transparent"
|
|
>
|
|
{selectAll ? 'Deselect All' : 'Select All'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
className="pr-2 border rounded-md border-bolt-elements-borderColor bg-bolt-elements-bg-depth-2"
|
|
style={{
|
|
maxHeight,
|
|
}}
|
|
>
|
|
{items.length > 0 ? (
|
|
<FixedSizeList
|
|
height={listHeight}
|
|
width="100%"
|
|
itemCount={items.length}
|
|
itemSize={60}
|
|
className="scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-bolt-elements-bg-depth-3"
|
|
>
|
|
{ItemRenderer}
|
|
</FixedSizeList>
|
|
) : (
|
|
<div className="text-center py-4 text-sm text-bolt-elements-textTertiary">No items to display</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
className="border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirm}
|
|
disabled={selectedItems.length === 0}
|
|
className="bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:pointer-events-none"
|
|
>
|
|
{confirmLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</RadixDialog.Root>
|
|
);
|
|
}
|