|
| 1 | +import { RefreshCw, Smartphone, Monitor, Terminal, Globe } from 'lucide-react'; |
| 2 | +import { Button } from '@/components/ui/button'; |
| 3 | +import { Input } from '@/components/ui/input'; |
| 4 | +import { useState, useEffect, useRef } from 'react'; |
| 5 | +import { consoleProxyScript } from '@/utils/consoleProxy'; |
| 6 | +import { ScrollArea } from '@/components/ui/scroll-area'; |
| 7 | +import type { FC } from 'react'; |
| 8 | + |
| 9 | +interface ConsoleMessage { |
| 10 | + level: 'log' | 'info' | 'warn' | 'error' | 'debug'; |
| 11 | + args: unknown[]; |
| 12 | + timestamp: number; |
| 13 | +} |
| 14 | + |
| 15 | +interface Props { |
| 16 | + defaultUrl?: string; |
| 17 | +} |
| 18 | + |
| 19 | +export const BrowserPreview: FC<Props> = ({ defaultUrl = 'http://localhost:8080' }) => { |
| 20 | + const [inputValue, setInputValue] = useState(defaultUrl); |
| 21 | + const [currentUrl, setCurrentUrl] = useState(defaultUrl); |
| 22 | + const [isMobile, setIsMobile] = useState(false); |
| 23 | + const [showConsole, setShowConsole] = useState(true); |
| 24 | + const [logs, setLogs] = useState<ConsoleMessage[]>([]); |
| 25 | + const iframeRef = useRef<HTMLIFrameElement>(null); |
| 26 | + |
| 27 | + // Clear logs when URL changes |
| 28 | + useEffect(() => { |
| 29 | + setLogs([]); |
| 30 | + }, [currentUrl]); |
| 31 | + |
| 32 | + const handleRefresh = () => { |
| 33 | + // Force refresh by appending a dummy parameter if URL is unchanged |
| 34 | + setCurrentUrl(inputValue === currentUrl ? `${inputValue}${inputValue.includes('?') ? '&' : '?'}_refresh=${Date.now()}` : inputValue); |
| 35 | + setLogs([]); // Clear logs on refresh |
| 36 | + }; |
| 37 | + |
| 38 | + const toggleMode = () => { |
| 39 | + setIsMobile((prev) => !prev); |
| 40 | + }; |
| 41 | + |
| 42 | + const toggleConsole = () => { |
| 43 | + setShowConsole((prev) => !prev); |
| 44 | + }; |
| 45 | + |
| 46 | + useEffect(() => { |
| 47 | + const handleMessage = (event: MessageEvent) => { |
| 48 | + if (event.data?.type === 'console') { |
| 49 | + setLogs((prev) => [ |
| 50 | + ...prev, |
| 51 | + { |
| 52 | + level: event.data.level, |
| 53 | + args: event.data.args, |
| 54 | + timestamp: Date.now(), |
| 55 | + }, |
| 56 | + ]); |
| 57 | + } |
| 58 | + }; |
| 59 | + |
| 60 | + window.addEventListener('message', handleMessage); |
| 61 | + return () => window.removeEventListener('message', handleMessage); |
| 62 | + }, []); |
| 63 | + |
| 64 | + // Inject console proxy script when iframe loads |
| 65 | + // NOTE: only works with same-origin URLs (we need a workaround to capture logs from cross-origin iframes) |
| 66 | + const handleIframeLoad = () => { |
| 67 | + const iframe = iframeRef.current; |
| 68 | + if (iframe?.contentWindow) { |
| 69 | + // Use Function constructor instead of eval for better type safety |
| 70 | + const script = new Function(consoleProxyScript); |
| 71 | + iframe.contentWindow.document.head.appendChild( |
| 72 | + Object.assign(iframe.contentWindow.document.createElement('script'), { |
| 73 | + textContent: `(${script.toString()})();`, |
| 74 | + }) |
| 75 | + ); |
| 76 | + } |
| 77 | + }; |
| 78 | + |
| 79 | + const clearLogs = () => { |
| 80 | + setLogs([]); |
| 81 | + }; |
| 82 | + |
| 83 | + return ( |
| 84 | + <div className="flex h-full flex-col"> |
| 85 | + <div className="flex items-center gap-2 border-b p-2"> |
| 86 | + <div className="relative flex-1"> |
| 87 | + <Globe className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> |
| 88 | + <Input |
| 89 | + type="text" |
| 90 | + value={inputValue} |
| 91 | + onChange={(e) => setInputValue(e.target.value)} |
| 92 | + onKeyDown={(e) => { |
| 93 | + if (e.key === 'Enter') { |
| 94 | + handleRefresh(); |
| 95 | + } |
| 96 | + }} |
| 97 | + className="flex-1 pl-8" |
| 98 | + /> |
| 99 | + </div> |
| 100 | + <Button variant="ghost" size="icon" onClick={handleRefresh} title="Refresh"> |
| 101 | + <RefreshCw className="h-4 w-4" /> |
| 102 | + </Button> |
| 103 | + <Button |
| 104 | + variant="ghost" |
| 105 | + size="icon" |
| 106 | + onClick={toggleMode} |
| 107 | + title={isMobile ? 'Switch to desktop' : 'Switch to mobile'} |
| 108 | + > |
| 109 | + {isMobile ? <Smartphone className="h-4 w-4" /> : <Monitor className="h-4 w-4" />} |
| 110 | + </Button> |
| 111 | + <Button |
| 112 | + variant="ghost" |
| 113 | + size="icon" |
| 114 | + onClick={toggleConsole} |
| 115 | + title={showConsole ? 'Hide Console' : 'Show Console'} |
| 116 | + > |
| 117 | + <Terminal className="h-4 w-4" /> |
| 118 | + </Button> |
| 119 | + </div> |
| 120 | + <div className={`relative flex-1 ${showConsole ? 'h-[60%]' : 'h-full'}`}> |
| 121 | + <div className="h-full border border-foreground/10 bg-muted/30 p-1"> |
| 122 | + <iframe |
| 123 | + ref={iframeRef} |
| 124 | + src={currentUrl} |
| 125 | + onLoad={handleIframeLoad} |
| 126 | + className={`h-full w-full rounded-sm bg-background shadow-md ${ |
| 127 | + isMobile ? 'mx-auto w-[375px]' : 'w-full' |
| 128 | + }`} |
| 129 | + title="Browser Preview" |
| 130 | + // sandbox="allow-scripts allow-same-origin" |
| 131 | + /> |
| 132 | + </div> |
| 133 | + </div> |
| 134 | + {showConsole && ( |
| 135 | + <div className="h-[40%] border-t"> |
| 136 | + <div className="flex items-center justify-between border-b px-2 py-1"> |
| 137 | + <span className="text-sm font-medium">Console</span> |
| 138 | + <div className="flex gap-2"> |
| 139 | + <Button variant="ghost" size="sm" onClick={clearLogs}> |
| 140 | + Clear |
| 141 | + </Button> |
| 142 | + </div> |
| 143 | + </div> |
| 144 | + <ScrollArea className="h-[calc(100%-2rem)]"> |
| 145 | + <div className="space-y-1 p-2"> |
| 146 | + {logs.map((log, i) => ( |
| 147 | + <div |
| 148 | + key={i} |
| 149 | + className={`font-mono text-sm ${ |
| 150 | + log.level === 'error' |
| 151 | + ? 'text-red-500' |
| 152 | + : log.level === 'warn' |
| 153 | + ? 'text-yellow-500' |
| 154 | + : 'text-foreground' |
| 155 | + }`} |
| 156 | + > |
| 157 | + {log.args.map((arg, j) => ( |
| 158 | + <span key={j}> |
| 159 | + {typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}{' '} |
| 160 | + </span> |
| 161 | + ))} |
| 162 | + </div> |
| 163 | + ))} |
| 164 | + </div> |
| 165 | + </ScrollArea> |
| 166 | + </div> |
| 167 | + )} |
| 168 | + </div> |
| 169 | + ); |
| 170 | +}; |
0 commit comments