|
|
|
|
| import { Button } from '@/components/ui/button'; |
| import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; |
| import { Input } from '@/components/ui/input'; |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; |
| import { cn } from '@/lib/utils'; |
| import { ChevronDownIcon } from 'lucide-react'; |
| import type { ComponentProps, ReactNode } from 'react'; |
| import { createContext, useContext, useEffect, useState } from 'react'; |
|
|
| export type WebPreviewContextValue = { |
| url: string; |
| setUrl: (url: string) => void; |
| consoleOpen: boolean; |
| setConsoleOpen: (open: boolean) => void; |
| }; |
|
|
| const WebPreviewContext = createContext<WebPreviewContextValue | null>(null); |
|
|
| const useWebPreview = () => { |
| const context = useContext(WebPreviewContext); |
| if (!context) { |
| throw new Error('WebPreview components must be used within a WebPreview'); |
| } |
| return context; |
| }; |
|
|
| export type WebPreviewProps = ComponentProps<'div'> & { |
| defaultUrl?: string; |
| onUrlChange?: (url: string) => void; |
| }; |
|
|
| export const WebPreview = ({ |
| className, |
| children, |
| defaultUrl = '', |
| onUrlChange, |
| ...props |
| }: WebPreviewProps) => { |
| const [url, setUrl] = useState(defaultUrl); |
| const [consoleOpen, setConsoleOpen] = useState(false); |
|
|
| const handleUrlChange = (newUrl: string) => { |
| setUrl(newUrl); |
| onUrlChange?.(newUrl); |
| }; |
|
|
| const contextValue: WebPreviewContextValue = { |
| url, |
| setUrl: handleUrlChange, |
| consoleOpen, |
| setConsoleOpen, |
| }; |
|
|
| return ( |
| <WebPreviewContext.Provider value={contextValue}> |
| <div |
| className={cn('flex size-full flex-col rounded-lg border bg-card', className)} |
| {...props} |
| > |
| {children} |
| </div> |
| </WebPreviewContext.Provider> |
| ); |
| }; |
|
|
| export type WebPreviewNavigationProps = ComponentProps<'div'>; |
|
|
| export const WebPreviewNavigation = ({ |
| className, |
| children, |
| ...props |
| }: WebPreviewNavigationProps) => ( |
| <div className={cn('flex items-center gap-1 border-b p-2', className)} {...props}> |
| {children} |
| </div> |
| ); |
|
|
| export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & { |
| tooltip?: string; |
| }; |
|
|
| export const WebPreviewNavigationButton = ({ |
| onClick, |
| disabled, |
| tooltip, |
| children, |
| ...props |
| }: WebPreviewNavigationButtonProps) => ( |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| className="h-8 w-8 p-0 hover:text-foreground" |
| disabled={disabled} |
| onClick={onClick} |
| size="sm" |
| variant="ghost" |
| {...props} |
| > |
| {children} |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent> |
| <p>{tooltip}</p> |
| </TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| ); |
|
|
| export type WebPreviewUrlProps = ComponentProps<typeof Input>; |
|
|
| export const WebPreviewUrl = ({ value, onChange, onKeyDown, ...props }: WebPreviewUrlProps) => { |
| const { url, setUrl } = useWebPreview(); |
| const [inputValue, setInputValue] = useState(url); |
|
|
| |
| useEffect(() => { |
| setInputValue(url); |
| }, [url]); |
|
|
| const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
| setInputValue(event.target.value); |
| onChange?.(event); |
| }; |
|
|
| const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
| if (event.key === 'Enter') { |
| const target = event.target as HTMLInputElement; |
| setUrl(target.value); |
| } |
| onKeyDown?.(event); |
| }; |
|
|
| return ( |
| <Input |
| className="h-8 flex-1 text-sm" |
| onChange={onChange ?? handleChange} |
| onKeyDown={handleKeyDown} |
| placeholder="Enter URL..." |
| value={value ?? inputValue} |
| {...props} |
| /> |
| ); |
| }; |
|
|
| export type WebPreviewBodyProps = ComponentProps<'iframe'> & { |
| loading?: ReactNode; |
| }; |
|
|
| export const WebPreviewBody = ({ className, loading, src, ...props }: WebPreviewBodyProps) => { |
| const { url } = useWebPreview(); |
|
|
| return ( |
| <div className="flex-1"> |
| <iframe |
| className={cn('size-full', className)} |
| sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" |
| src={(src ?? url) || undefined} |
| title="Preview" |
| {...props} |
| /> |
| {loading} |
| </div> |
| ); |
| }; |
|
|
| export type WebPreviewConsoleProps = ComponentProps<'div'> & { |
| logs?: Array<{ |
| level: 'log' | 'warn' | 'error'; |
| message: string; |
| timestamp: Date; |
| }>; |
| }; |
|
|
| export const WebPreviewConsole = ({ |
| className, |
| logs = [], |
| children, |
| ...props |
| }: WebPreviewConsoleProps) => { |
| const { consoleOpen, setConsoleOpen } = useWebPreview(); |
|
|
| return ( |
| <Collapsible |
| className={cn('border-t bg-muted/50 font-mono text-sm', className)} |
| onOpenChange={setConsoleOpen} |
| open={consoleOpen} |
| {...props} |
| > |
| <CollapsibleTrigger asChild> |
| <Button |
| className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50" |
| variant="ghost" |
| > |
| Console |
| <ChevronDownIcon |
| className={cn('h-4 w-4 transition-transform duration-200', consoleOpen && 'rotate-180')} |
| /> |
| </Button> |
| </CollapsibleTrigger> |
| <CollapsibleContent |
| className={cn( |
| 'px-4 pb-4', |
| 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in', |
| )} |
| > |
| <div className="max-h-48 space-y-1 overflow-y-auto"> |
| {logs.length === 0 ? ( |
| <p className="text-muted-foreground">No console output</p> |
| ) : ( |
| logs.map((log, index) => ( |
| <div |
| className={cn( |
| 'text-xs', |
| log.level === 'error' && 'text-destructive', |
| log.level === 'warn' && 'text-yellow-600', |
| log.level === 'log' && 'text-foreground', |
| )} |
| key={`${log.timestamp.getTime()}-${index}`} |
| > |
| <span className="text-muted-foreground">{log.timestamp.toLocaleTimeString()}</span>{' '} |
| {log.message} |
| </div> |
| )) |
| )} |
| {children} |
| </div> |
| </CollapsibleContent> |
| </Collapsible> |
| ); |
| }; |
|
|