Skip to main content
Glama
Browser.tsx11.4 kB
'use client'; import { cva } from 'class-variance-authority'; import { ArrowLeft, ArrowRight, RotateCw } from 'lucide-react'; import { type CSSProperties, type FormEvent, type HTMLAttributes, type RefObject, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { useIntlayer } from 'react-intlayer'; import { cn } from '../../utils/cn'; import { Button } from '../Button'; import { Input, inputVariants } from '../Input'; export type BrowserProps = { initialUrl?: string; path?: string; className?: string; style?: CSSProperties; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; 'aria-label'?: string; sandbox?: string; ref?: RefObject<HTMLIFrameElement | null>; domainRestriction?: string; } & HTMLAttributes<HTMLIFrameElement>; export const Browser = ({ initialUrl = 'https://example.com', path, className, style, size = 'md', 'aria-label': ariaLabel, sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads', ref, domainRestriction, ...props }: BrowserProps) => { // --- State ----------------------------------------------------------------- const [inputUrl, setInputUrl] = useState(initialUrl); const [currentUrl, setCurrentUrl] = useState(initialUrl); // History Management const [history, setHistory] = useState<string[]>([initialUrl]); const [currentIndex, setCurrentIndex] = useState(0); const [error, setError] = useState<string | null>(null); const [submitted, setSubmitted] = useState(false); const internalIframeRef = useRef<HTMLIFrameElement>(null); useImperativeHandle(ref, () => internalIframeRef.current!, []); const content = useIntlayer('browser'); // --- Effects --------------------------------------------------------------- // Reset everything if initialUrl changes completely useEffect(() => { setInputUrl(initialUrl); setCurrentUrl(initialUrl); setHistory([initialUrl]); setCurrentIndex(0); setError(null); setSubmitted(false); }, [initialUrl]); // Sync external path changes with the URL bar and History useEffect(() => { if (!path) return; try { const baseOrigin = domainRestriction ?? initialUrl; const origin = new URL(baseOrigin).origin; const fullUrl = `${origin}${path}`; // Update Input (Always update the visual bar) setInputUrl(fullUrl); // Check internal iframe state to avoid reload if already there let isAlreadyAtUrl = false; if (internalIframeRef.current?.contentWindow) { try { const currentIframeHref = internalIframeRef.current.contentWindow.location.href; if (new URL(currentIframeHref).href === new URL(fullUrl).href) { isAlreadyAtUrl = true; } } catch { // Cross-origin access ignored } } // Update History Stack // We perform this check regardless of `isAlreadyAtUrl`. // If the path changed (even internally), we want to record it in the arrow stack. if (history[currentIndex] !== fullUrl) { setHistory((prev) => { const newHistory = prev.slice(0, currentIndex + 1); newHistory.push(fullUrl); return newHistory; }); setCurrentIndex((prev) => prev + 1); } // Navigate (Update src) only if NOT already there // This prevents the iframe from refreshing when the user navigated inside it. if (!isAlreadyAtUrl) { setCurrentUrl(fullUrl); } setError(null); } catch { // Ignore invalid paths } }, [path, domainRestriction, initialUrl]); // Removed currentIndex dependency to prevent loops // --- Navigation Logic ------------------------------------------------------ const handleNavigateTo = (url: string) => { try { const validated = normalizeUrl(url); // If we are navigating to the exact same URL, just reload if (validated === currentUrl) { handleReload(); return; } setCurrentUrl(validated); setInputUrl(validated); setError(null); // Update History: Slice future if we went back, then push new const newHistory = history.slice(0, currentIndex + 1); newHistory.push(validated); setHistory(newHistory); setCurrentIndex(newHistory.length - 1); } catch (e) { if ( e instanceof Error && e.message === 'URL does not match allowed domain' && domainRestriction ) { setError( content.domainRestrictionError?.value ?? `Only URLs from ${domainRestriction} are allowed.` ); } else { setError(content.errorMessage.value); } } }; const handleBack = () => { if (currentIndex > 0) { const newIndex = currentIndex - 1; const prevUrl = history[newIndex]; setCurrentIndex(newIndex); setCurrentUrl(prevUrl); setInputUrl(prevUrl); setError(null); } }; const handleForward = () => { if (currentIndex < history.length - 1) { const newIndex = currentIndex + 1; const nextUrl = history[newIndex]; setCurrentIndex(newIndex); setCurrentUrl(nextUrl); setInputUrl(nextUrl); setError(null); } }; const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setSubmitted(true); handleNavigateTo(inputUrl); }; const handleReload = () => { if (internalIframeRef.current) { // Create a clean reload effect const src = internalIframeRef.current.src; internalIframeRef.current.src = ''; setTimeout(() => { if (internalIframeRef.current) internalIframeRef.current.src = src; }, 50); } }; // --- Validation Helpers ---------------------------------------------------- const isValidHostname = (host: string) => { if (host === 'localhost') return true; if (/^(\d{1,3}\.){3}\d{1,3}$/.test(host)) return true; if (/^[a-f0-9:]+$/i.test(host)) return true; if (!/^[a-z0-9.-]+$/i.test(host)) return false; if (/^[-.]/.test(host) || /[-.]$/.test(host)) return false; if (host.includes('..')) return false; if (!host.includes('.')) return false; return true; }; const getRestrictionOrigin = (): URL | null => { if (!domainRestriction) return null; try { return new URL(domainRestriction); } catch { return null; } }; const normalizeUrl = (raw: string) => { const trimmed = raw.trim(); if (!trimmed || /\s/.test(trimmed)) throw new Error('Invalid'); const restrictionOrigin = getRestrictionOrigin(); const isRelativePath = trimmed.startsWith('/') && !trimmed.startsWith('//'); if (isRelativePath) { if (restrictionOrigin) { return new URL(`${restrictionOrigin.origin}${trimmed}`).toString(); } return new URL(`${new URL(currentUrl).origin}${trimmed}`).toString(); } const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed); const candidate = hasProtocol ? trimmed : `https://${trimmed}`; const url = new URL(candidate); if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error('Only http(s) is allowed'); } if (!isValidHostname(url.hostname)) throw new Error('Invalid host'); if (restrictionOrigin) { const urlMatches = url.hostname === restrictionOrigin.hostname && url.protocol === restrictionOrigin.protocol && (restrictionOrigin.port === '' || url.port === restrictionOrigin.port || url.host === restrictionOrigin.host); if (!urlMatches) throw new Error('URL does not match allowed domain'); } return url.toString(); }; const showError = submitted && !!error; const canGoBack = currentIndex > 0; const canGoForward = currentIndex < history.length - 1; return ( <section className={cn( 'flex w-full flex-col overflow-hidden rounded-xl bg-background shadow-[0_4px_12px_rgba(0,0,0,0.4),0_0_1px_rgba(0,0,0,0.2)]', className )} style={style} aria-label={ariaLabel ?? content.ariaLabel.value} > {/* Top bar */} <div className="relative z-10 flex shrink-0 items-center gap-3 rounded-t-xl bg-text/15 px-4 py-2"> {/* Navigation Controls */} <div className="flex items-center gap-1"> <Button type="button" onClick={handleBack} disabled={!canGoBack} variant="hoverable" size="icon-md" label={content.backButtonLabel.value} Icon={ArrowLeft} /> <Button type="button" onClick={handleForward} disabled={!canGoForward} variant="hoverable" size="icon-md" label={content.forwardButtonLabel.value} Icon={ArrowRight} /> </div> {/* URL Bar */} <form onSubmit={handleSubmit} noValidate className={cn( inputVariants(), 'flex w-full gap-2 rounded-xl p-0.5! supports-[corner-shape:squircle]:rounded-2xl', 'bg-neutral/10 text-text/50 placeholder:text-neutral/80' )} > <label htmlFor="browser-url" className="sr-only"> {content.urlLabel.value} </label> <Input id="browser-url" type="text" inputMode="url" spellCheck={false} autoCapitalize="off" variant="invisible" className="ml-3 p-0!" size="sm" autoCorrect="off" value={inputUrl} onChange={(e) => { setInputUrl(e.target.value); if (showError) setError(null); }} placeholder={content.urlPlaceholder.value} aria-label={content.urlLabel.value} aria-invalid={showError} aria-describedby={showError ? 'browser-url-error' : undefined} /> <Button type="button" onClick={handleReload} variant="hoverable" size="icon-md" className="p-1!" label={'content.reloadButtonTitle.value'} Icon={RotateCw} /> {/* invisible submit */} <button type="submit" className="sr-only absolute" tabIndex={-1} /> </form> {/* Error Message Tooltip */} {showError && ( <div className="absolute top-full left-4 z-20 mt-1"> <p id="browser-url-error" role="alert" aria-live="assertive" className="rounded-md bg-red-900/90 px-3 py-1.5 text-red-100 text-xs shadow-md backdrop-blur-sm" > {error} </p> </div> )} </div> {/* Iframe */} <div className="relative z-0 flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-b-xl bg-background"> <iframe ref={internalIframeRef} src={currentUrl} title={content.iframeTitle.value} className="size-full flex-1" sandbox={sandbox} loading="lazy" aria-live="polite" {...props} /> </div> </section> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server