Spaces:
Running
Running
| import React, { useCallback, useEffect, useRef, useState } from "react"; | |
| import { | |
| ExternalLink, | |
| Loader2, | |
| RefreshCw, | |
| ArrowDown, | |
| ArrowUp, | |
| Keyboard, | |
| Globe, | |
| Lock, | |
| } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { | |
| browserClickAt, | |
| browserOpen, | |
| browserScroll, | |
| browserSnapshot, | |
| browserKeyFire, | |
| } from "../lib/browserSession"; | |
| import { mapImageClickToViewport } from "../lib/browserClickCoords"; | |
| import { formatApiError } from "../lib/api"; | |
| import { IFRAME_ALLOW, IFRAME_SANDBOX } from "../lib/iframePreview"; | |
| import { | |
| isVideoStreamUrl, | |
| videoEmbedUrl, | |
| videoPlatformLabel, | |
| } from "../lib/sitePreview"; | |
| import { | |
| prefersTouchKeyboard, | |
| useVisualViewportKeyboard, | |
| } from "../lib/useVisualViewportKeyboard"; | |
| const SPECIAL_KEYS = new Set([ | |
| "Enter", "Tab", "Escape", "Backspace", "Delete", | |
| "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", | |
| "Home", "End", "PageUp", "PageDown", "Space", | |
| ]); | |
| const SCROLL_THROTTLE_MS = 16; | |
| const KEY_SNAPSHOT_MS = 80; | |
| const LIVE_SYNC_MS = 2500; | |
| /** Pause sync après interaction utilisateur (regarder vidéo, cliquer, etc.) */ | |
| const USER_ACTIVITY_PAUSE_MS = 120_000; | |
| function mapDomKey(e) { | |
| if (e.ctrlKey || e.metaKey || e.altKey) return null; | |
| if (e.key === " ") return "Space"; | |
| if (SPECIAL_KEYS.has(e.key)) return e.key; | |
| if (e.key.length === 1) return { text: e.key }; | |
| return null; | |
| } | |
| function urlLabel(raw) { | |
| if (!raw) return ""; | |
| try { | |
| const u = new URL(raw); | |
| const path = u.pathname === "/" ? "" : u.pathname; | |
| return `${u.hostname}${path}${u.search}`; | |
| } catch { | |
| return raw.replace(/^https?:\/\//, ""); | |
| } | |
| } | |
| function isSecureUrl(raw) { | |
| try { | |
| return new URL(raw).protocol === "https:"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export default function InteractiveBrowser({ | |
| frame, | |
| sessionId: sessionIdProp, | |
| onFrameUpdate, | |
| compact = false, | |
| autoOpen = true, | |
| embedded = false, | |
| liveSync = true, | |
| }) { | |
| const sessionId = sessionIdProp || frame?.session_id || "default"; | |
| const imgRef = useRef(null); | |
| const containerRef = useRef(null); | |
| const browserRootRef = useRef(null); | |
| const mobileInputRef = useRef(null); | |
| const urlInputRef = useRef(null); | |
| const touchKeyboardRef = useRef(prefersTouchKeyboard()); | |
| const scrollAccumRef = useRef(0); | |
| const scrollTimerRef = useRef(null); | |
| const scrollPendingRef = useRef(false); | |
| const keySnapshotTimerRef = useRef(null); | |
| const flushScrollAccumRef = useRef(() => {}); | |
| const actionGenRef = useRef(0); | |
| const liveSyncGenRef = useRef(0); | |
| const userActivityUntilRef = useRef(0); | |
| const embedHostRef = useRef( | |
| typeof window !== "undefined" ? window.location.hostname : "localhost", | |
| ); | |
| const [local, setLocal] = useState(frame || null); | |
| const [navigating, setNavigating] = useState(false); | |
| const [refreshing, setRefreshing] = useState(false); | |
| const [loadError, setLoadError] = useState(""); | |
| const [urlInput, setUrlInput] = useState(frame?.url || ""); | |
| const [clickMark, setClickMark] = useState(null); | |
| const [keyboardFocused, setKeyboardFocused] = useState(false); | |
| const { inset: keyboardInset, open: keyboardOpen } = useVisualViewportKeyboard({ | |
| enabled: keyboardFocused, | |
| }); | |
| const autoOpenedRef = useRef(false); | |
| const pageUrl = local?.url || frame?.url || ""; | |
| const embedUrl = videoEmbedUrl(pageUrl, embedHostRef.current); | |
| const showEmbed = Boolean(embedUrl); | |
| const isStreamPage = isVideoStreamUrl(pageUrl); | |
| const viewportW = local?.viewport_width || frame?.viewport_width || 1280; | |
| const viewportH = local?.viewport_height || frame?.viewport_height || 900; | |
| const secure = isSecureUrl(pageUrl); | |
| useEffect(() => { | |
| if (!frame) return; | |
| setLocal((prev) => { | |
| const same = | |
| frame.url === prev?.url && | |
| frame.screenshot_base64 === prev?.screenshot_base64 && | |
| frame.title === prev?.title && | |
| (frame.elements?.length || 0) === (prev?.elements?.length || 0); | |
| if (same) return prev; | |
| return { ...prev, ...frame }; | |
| }); | |
| if (frame.url) setUrlInput(frame.url); | |
| }, [frame]); | |
| const hasValidScreenshot = (data) => { | |
| const b64 = data?.screenshot_base64; | |
| if (!b64 || typeof b64 !== "string") return false; | |
| if (b64.includes("truncated") || b64.includes("[screenshot:")) return false; | |
| return b64.length > 500; | |
| }; | |
| const applyFrame = useCallback( | |
| (next) => { | |
| if (!next) return; | |
| setLocal(next); | |
| setUrlInput(next.url || ""); | |
| setLoadError(""); | |
| onFrameUpdate?.(next); | |
| }, | |
| [onFrameUpdate], | |
| ); | |
| const runNavigate = useCallback( | |
| async (fn) => { | |
| setNavigating(true); | |
| setLoadError(""); | |
| try { | |
| const next = await fn(); | |
| if (next) applyFrame(next); | |
| return next; | |
| } catch (e) { | |
| const msg = formatApiError(e, "Action navigateur échouée"); | |
| setLoadError(msg); | |
| toast.error(msg); | |
| return null; | |
| } finally { | |
| setNavigating(false); | |
| } | |
| }, | |
| [applyFrame], | |
| ); | |
| const runRefresh = useCallback( | |
| async (fn) => { | |
| const gen = ++actionGenRef.current; | |
| setRefreshing(true); | |
| try { | |
| const next = await fn(); | |
| if (next && gen === actionGenRef.current) applyFrame(next); | |
| return next; | |
| } catch (e) { | |
| toast.error(formatApiError(e, "Action navigateur échouée")); | |
| return null; | |
| } finally { | |
| if (gen === actionGenRef.current) setRefreshing(false); | |
| } | |
| }, | |
| [applyFrame], | |
| ); | |
| const markUserActivity = useCallback(() => { | |
| userActivityUntilRef.current = Date.now() + USER_ACTIVITY_PAUSE_MS; | |
| }, []); | |
| const runSilentSync = useCallback(async () => { | |
| if (showEmbed || isStreamPage || navigating || refreshing || document.hidden) return; | |
| if (Date.now() < userActivityUntilRef.current) return; | |
| const gen = ++liveSyncGenRef.current; | |
| try { | |
| const next = await browserSnapshot(sessionId); | |
| if (!next || gen !== liveSyncGenRef.current) return; | |
| setLocal((prev) => { | |
| const unchanged = | |
| prev?.url === next.url && | |
| prev?.screenshot_base64 === next.screenshot_base64 && | |
| prev?.title === next.title; | |
| if (unchanged) return prev; | |
| const merged = { ...prev, ...next }; | |
| onFrameUpdate?.(merged); | |
| return merged; | |
| }); | |
| if (next.url) setUrlInput(next.url); | |
| } catch { | |
| /* sync silencieux — pas de toast */ | |
| } | |
| }, [showEmbed, isStreamPage, navigating, refreshing, sessionId, onFrameUpdate]); | |
| useEffect(() => { | |
| if (!liveSync || showEmbed || isStreamPage || !hasValidScreenshot(local)) return undefined; | |
| const id = setInterval(runSilentSync, LIVE_SYNC_MS); | |
| const onVis = () => { | |
| if (!document.hidden) runSilentSync(); | |
| }; | |
| document.addEventListener("visibilitychange", onVis); | |
| return () => { | |
| clearInterval(id); | |
| document.removeEventListener("visibilitychange", onVis); | |
| }; | |
| }, [liveSync, showEmbed, isStreamPage, local?.screenshot_base64, runSilentSync]); | |
| const scheduleKeySnapshot = useCallback(() => { | |
| if (keySnapshotTimerRef.current) clearTimeout(keySnapshotTimerRef.current); | |
| keySnapshotTimerRef.current = setTimeout(() => { | |
| keySnapshotTimerRef.current = null; | |
| runRefresh(() => browserSnapshot(sessionId)); | |
| }, KEY_SNAPSHOT_MS); | |
| }, [runRefresh, sessionId]); | |
| const runScroll = useCallback( | |
| async (direction, amount) => { | |
| if (scrollPendingRef.current) return; | |
| scrollPendingRef.current = true; | |
| try { | |
| await runRefresh(() => browserScroll(sessionId, direction, amount)); | |
| } finally { | |
| scrollPendingRef.current = false; | |
| if (Math.abs(scrollAccumRef.current) >= 8 && !scrollTimerRef.current) { | |
| scrollTimerRef.current = setTimeout( | |
| () => flushScrollAccumRef.current(), | |
| SCROLL_THROTTLE_MS, | |
| ); | |
| } | |
| } | |
| }, | |
| [sessionId, runRefresh], | |
| ); | |
| const flushScrollAccum = useCallback(() => { | |
| scrollTimerRef.current = null; | |
| const delta = scrollAccumRef.current; | |
| scrollAccumRef.current = 0; | |
| if (Math.abs(delta) < 8) return; | |
| const direction = delta > 0 ? "down" : "up"; | |
| const amount = Math.round(Math.min(Math.abs(delta) * 1.2, 720)); | |
| runScroll(direction, amount); | |
| }, [runScroll]); | |
| flushScrollAccumRef.current = flushScrollAccum; | |
| const handleWheel = useCallback( | |
| (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (navigating) return; | |
| scrollAccumRef.current += e.deltaY; | |
| if (scrollTimerRef.current) return; | |
| scrollTimerRef.current = setTimeout( | |
| () => flushScrollAccumRef.current(), | |
| SCROLL_THROTTLE_MS, | |
| ); | |
| }, | |
| [navigating], | |
| ); | |
| useEffect(() => { | |
| const el = containerRef.current; | |
| if (!el || !hasValidScreenshot(local)) return undefined; | |
| el.addEventListener("wheel", handleWheel, { passive: false }); | |
| return () => el.removeEventListener("wheel", handleWheel); | |
| }, [handleWheel, local?.screenshot_base64]); | |
| useEffect(() => { | |
| return () => { | |
| if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); | |
| if (keySnapshotTimerRef.current) clearTimeout(keySnapshotTimerRef.current); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (!autoOpen || !pageUrl || showEmbed || hasValidScreenshot(local) || navigating || autoOpenedRef.current) return; | |
| autoOpenedRef.current = true; | |
| runNavigate(() => browserOpen(pageUrl, sessionId)); | |
| }, [autoOpen, pageUrl, showEmbed, local?.screenshot_base64, navigating, runNavigate, sessionId]); | |
| const fireKeyPayload = useCallback( | |
| (payload) => { | |
| browserKeyFire(sessionId, payload).catch(() => {}); | |
| scheduleKeySnapshot(); | |
| }, | |
| [sessionId, scheduleKeySnapshot], | |
| ); | |
| const handleKeyDown = useCallback( | |
| (e) => { | |
| if (!keyboardFocused || navigating) return; | |
| if (urlInputRef.current && document.activeElement === urlInputRef.current) return; | |
| const mapped = mapDomKey(e); | |
| if (!mapped) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const payload = typeof mapped === "string" ? { key: mapped } : { text: mapped.text }; | |
| fireKeyPayload(payload); | |
| }, | |
| [keyboardFocused, navigating, fireKeyPayload], | |
| ); | |
| useEffect(() => { | |
| if (!keyboardFocused) return undefined; | |
| const onKey = (e) => { | |
| if (document.activeElement !== containerRef.current) return; | |
| handleKeyDown(e); | |
| }; | |
| window.addEventListener("keydown", onKey, true); | |
| return () => window.removeEventListener("keydown", onKey, true); | |
| }, [keyboardFocused, handleKeyDown]); | |
| const handleMobileBeforeInput = useCallback( | |
| (e) => { | |
| if (!keyboardFocused || navigating) return; | |
| if (e.inputType === "deleteContentBackward") { | |
| e.preventDefault(); | |
| fireKeyPayload({ key: "Backspace" }); | |
| } | |
| }, | |
| [keyboardFocused, navigating, fireKeyPayload], | |
| ); | |
| const handleMobileInput = useCallback( | |
| (e) => { | |
| if (!keyboardFocused || navigating) return; | |
| const { value } = e.target; | |
| if (!value) return; | |
| for (const char of value) { | |
| if (char) fireKeyPayload({ text: char }); | |
| } | |
| e.target.value = ""; | |
| }, | |
| [keyboardFocused, navigating, fireKeyPayload], | |
| ); | |
| const activateKeyboardCapture = useCallback(() => { | |
| if (navigating || showEmbed) return; | |
| markUserActivity(); | |
| setKeyboardFocused(true); | |
| if (touchKeyboardRef.current && mobileInputRef.current) { | |
| mobileInputRef.current.focus({ preventScroll: true }); | |
| return; | |
| } | |
| containerRef.current?.focus({ preventScroll: true }); | |
| }, [navigating, showEmbed, markUserActivity]); | |
| const handleContainerBlur = () => { | |
| requestAnimationFrame(() => { | |
| const active = document.activeElement; | |
| const root = containerRef.current; | |
| if (root?.contains(active)) return; | |
| if (mobileInputRef.current === active) return; | |
| if (urlInputRef.current === active) return; | |
| setKeyboardFocused(false); | |
| }); | |
| }; | |
| useEffect(() => { | |
| const root = browserRootRef.current; | |
| if (!root) return undefined; | |
| root.style.setProperty("--emo-keyboard-inset", `${keyboardInset}px`); | |
| return () => root.style.removeProperty("--emo-keyboard-inset"); | |
| }, [keyboardInset]); | |
| const screenshotSrc = hasValidScreenshot(local) | |
| ? `data:image/jpeg;base64,${local.screenshot_base64}` | |
| : null; | |
| const handleScreenshotClick = (e) => { | |
| if (navigating || !imgRef.current) return; | |
| markUserActivity(); | |
| activateKeyboardCapture(); | |
| const coords = mapImageClickToViewport( | |
| imgRef.current, | |
| e.clientX, | |
| e.clientY, | |
| viewportW, | |
| viewportH, | |
| ); | |
| if (!coords) return; | |
| const rect = imgRef.current.getBoundingClientRect(); | |
| setClickMark({ | |
| x: e.clientX - rect.left, | |
| y: e.clientY - rect.top, | |
| }); | |
| setTimeout(() => setClickMark(null), 600); | |
| runRefresh(() => browserClickAt(coords.x, coords.y, sessionId)); | |
| }; | |
| const busy = navigating; | |
| if (!pageUrl && !screenshotSrc && !showEmbed) return null; | |
| const viewportHeight = compact ? 280 : 520; | |
| return ( | |
| <div | |
| ref={browserRootRef} | |
| className={`emo-browser ${embedded ? "border-0 shadow-none" : ""}`} | |
| data-testid="interactive-browser" | |
| data-keyboard-open={keyboardOpen ? "true" : "false"} | |
| > | |
| {/* Chrome toolbar */} | |
| <div className="emo-browser-chrome"> | |
| <div className="emo-browser-dots" aria-hidden> | |
| <span className="emo-browser-dot" style={{ background: "#ff5f57" }} /> | |
| <span className="emo-browser-dot" style={{ background: "#febc2e" }} /> | |
| <span className="emo-browser-dot" style={{ background: "#28c840" }} /> | |
| </div> | |
| <button | |
| type="button" | |
| title="Actualiser" | |
| disabled={busy} | |
| onClick={() => runRefresh(() => browserSnapshot(sessionId))} | |
| className="emo-icon-btn" | |
| > | |
| {refreshing ? ( | |
| <Loader2 size={14} className="animate-spin" style={{ color: "var(--emo-accent)" }} /> | |
| ) : ( | |
| <RefreshCw size={14} /> | |
| )} | |
| </button> | |
| <div className="emo-browser-url-bar"> | |
| {secure ? ( | |
| <Lock size={12} className="flex-shrink-0" style={{ color: "var(--emo-success-text)" }} /> | |
| ) : ( | |
| <Globe size={12} className="flex-shrink-0 text-muted-em" /> | |
| )} | |
| <input | |
| ref={urlInputRef} | |
| type="url" | |
| value={urlInput} | |
| onChange={(e) => setUrlInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| e.stopPropagation(); | |
| if (e.key === "Enter" && urlInput.trim()) { | |
| runNavigate(() => browserOpen(urlInput.trim(), sessionId)); | |
| } | |
| }} | |
| onFocus={() => { | |
| setKeyboardFocused(false); | |
| mobileInputRef.current?.blur(); | |
| }} | |
| placeholder="https://…" | |
| title={local?.title || pageUrl} | |
| className="emo-browser-url-input" | |
| disabled={busy} | |
| /> | |
| </div> | |
| {local?.url && ( | |
| <a | |
| href={local.url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="emo-icon-btn" | |
| title="Ouvrir dans le navigateur" | |
| > | |
| <ExternalLink size={14} style={{ color: "var(--emo-link)" }} /> | |
| </a> | |
| )} | |
| <div className="hidden sm:flex items-center gap-0.5"> | |
| {!showEmbed && ( | |
| <button | |
| type="button" | |
| title={keyboardFocused ? "Désactiver le clavier distant" : "Activer le clavier distant"} | |
| disabled={busy} | |
| onClick={() => { | |
| if (keyboardFocused) { | |
| setKeyboardFocused(false); | |
| mobileInputRef.current?.blur(); | |
| containerRef.current?.blur(); | |
| } else { | |
| activateKeyboardCapture(); | |
| } | |
| }} | |
| className="emo-icon-btn" | |
| data-keyboard-toggle="true" | |
| aria-pressed={keyboardFocused} | |
| > | |
| <Keyboard size={14} style={{ color: keyboardFocused ? "var(--emo-accent)" : undefined }} /> | |
| </button> | |
| )} | |
| <button | |
| type="button" | |
| title="Défiler vers le haut" | |
| disabled={busy} | |
| onClick={() => runScroll("up", 480)} | |
| className="emo-icon-btn" | |
| > | |
| <ArrowUp size={14} /> | |
| </button> | |
| <button | |
| type="button" | |
| title="Défiler vers le bas" | |
| disabled={busy} | |
| onClick={() => runScroll("down", 480)} | |
| className="emo-icon-btn" | |
| > | |
| <ArrowDown size={14} /> | |
| </button> | |
| </div> | |
| <div className="emo-browser-mobile-actions sm:hidden"> | |
| <button | |
| type="button" | |
| title="Défiler vers le haut" | |
| disabled={busy} | |
| onClick={() => runScroll("up", 480)} | |
| className="emo-icon-btn" | |
| aria-label="Défiler vers le haut" | |
| > | |
| <ArrowUp size={14} /> | |
| </button> | |
| <button | |
| type="button" | |
| title="Défiler vers le bas" | |
| disabled={busy} | |
| onClick={() => runScroll("down", 480)} | |
| className="emo-icon-btn" | |
| aria-label="Défiler vers le bas" | |
| > | |
| <ArrowDown size={14} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Viewport */} | |
| {showEmbed ? ( | |
| <div | |
| className="emo-browser-viewport" | |
| data-testid="browser-video-embed" | |
| onPointerDown={markUserActivity} | |
| > | |
| <iframe | |
| key={embedUrl} | |
| title={local?.title || videoPlatformLabel(pageUrl)} | |
| src={embedUrl} | |
| className="w-full border-0 block" | |
| style={{ height: viewportHeight, background: "#000" }} | |
| sandbox={IFRAME_SANDBOX} | |
| allow={IFRAME_ALLOW} | |
| allowFullScreen | |
| /> | |
| </div> | |
| ) : screenshotSrc ? ( | |
| <div | |
| ref={containerRef} | |
| tabIndex={touchKeyboardRef.current ? -1 : 0} | |
| onFocus={() => { | |
| markUserActivity(); | |
| setKeyboardFocused(true); | |
| }} | |
| onBlur={handleContainerBlur} | |
| onPointerDown={(e) => { | |
| if (e.pointerType === "mouse" && e.button !== 0) return; | |
| if (e.target.closest(".emo-browser-keyboard-input")) return; | |
| markUserActivity(); | |
| }} | |
| className="emo-browser-viewport" | |
| data-focused={keyboardFocused ? "true" : "false"} | |
| data-compact={compact ? "true" : "false"} | |
| data-testid="browser-keyboard-capture" | |
| data-keyboard-focused={keyboardFocused ? "true" : "false"} | |
| > | |
| <input | |
| ref={mobileInputRef} | |
| type="text" | |
| inputMode="text" | |
| autoComplete="off" | |
| autoCorrect="off" | |
| autoCapitalize="off" | |
| spellCheck={false} | |
| enterKeyHint="done" | |
| aria-label="Saisie clavier pour la page distante" | |
| className="emo-browser-keyboard-input" | |
| tabIndex={-1} | |
| onFocus={() => setKeyboardFocused(true)} | |
| onBlur={handleContainerBlur} | |
| onKeyDown={handleKeyDown} | |
| onBeforeInput={handleMobileBeforeInput} | |
| onInput={handleMobileInput} | |
| /> | |
| {keyboardFocused && ( | |
| <div | |
| className="emo-browser-badge emo-browser-badge--keyboard flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-medium" | |
| style={{ | |
| background: "var(--emo-accent)", | |
| color: "var(--emo-on-accent)", | |
| boxShadow: "0 4px 12px var(--emo-glow)", | |
| }} | |
| > | |
| <Keyboard size={11} /> | |
| Clavier actif | |
| </div> | |
| )} | |
| <img | |
| ref={imgRef} | |
| src={screenshotSrc} | |
| alt={local?.title || urlLabel(pageUrl) || "Page web"} | |
| onClick={handleScreenshotClick} | |
| className="w-full h-auto block select-none emo-browser-screenshot" | |
| style={{ | |
| objectFit: "contain", | |
| objectPosition: "top", | |
| cursor: busy ? "wait" : "crosshair", | |
| }} | |
| draggable={false} | |
| /> | |
| {clickMark && ( | |
| <span | |
| className="emo-browser-click-mark rounded-full animate-ping" | |
| style={{ | |
| left: clickMark.x - 10, | |
| top: clickMark.y - 10, | |
| width: 20, | |
| height: 20, | |
| border: "2px solid var(--emo-accent)", | |
| background: "var(--emo-accent-soft)", | |
| }} | |
| /> | |
| )} | |
| {refreshing && ( | |
| <div | |
| className="emo-browser-badge emo-browser-badge--refresh flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium" | |
| style={{ background: "var(--emo-surface)", border: "1px solid var(--emo-border)", color: "var(--emo-text-muted)" }} | |
| aria-hidden | |
| > | |
| <span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--emo-accent)" }} /> | |
| Mise à jour… | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div | |
| className="emo-browser-viewport flex flex-col items-center justify-center gap-4 px-6" | |
| style={{ minHeight: compact ? 200 : 320 }} | |
| > | |
| {navigating ? ( | |
| <> | |
| <Loader2 size={32} className="animate-spin" style={{ color: "var(--emo-accent)" }} /> | |
| <p className="text-sm text-center text-muted-em">Chargement du navigateur…</p> | |
| </> | |
| ) : ( | |
| <> | |
| <div | |
| className="w-14 h-14 rounded-2xl flex items-center justify-center" | |
| style={{ background: "var(--emo-accent-soft)" }} | |
| > | |
| <Globe size={24} style={{ color: "var(--emo-accent)", opacity: 0.7 }} /> | |
| </div> | |
| <p className="text-sm px-4 text-center text-muted-em max-w-xs"> | |
| {loadError || "Appuie sur Entrée dans la barre d'URL pour ouvrir la page."} | |
| </p> | |
| {loadError && pageUrl && ( | |
| <button | |
| type="button" | |
| onClick={() => { | |
| autoOpenedRef.current = false; | |
| runNavigate(() => browserOpen(pageUrl, sessionId)); | |
| }} | |
| className="emo-btn-primary px-4 py-2 text-xs" | |
| > | |
| Réessayer | |
| </button> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| )} | |
| {/* Status strip */} | |
| <div className="emo-browser-status"> | |
| <span className="flex items-center gap-2"> | |
| <span | |
| className="w-1.5 h-1.5 rounded-full" | |
| style={{ background: busy ? "#fbbf24" : "var(--emo-status-online)" }} | |
| /> | |
| {touchKeyboardRef.current ? "Clic · Toucher · Clavier" : "Clic · Molette · Clavier"} | |
| {showEmbed && ` · ${videoPlatformLabel(pageUrl)}`} | |
| {isStreamPage && !showEmbed && " · Flux vidéo (sync pause)"} | |
| </span> | |
| {local?.title && ( | |
| <span className="truncate max-w-[55%] font-medium" title={local.title}> | |
| {local.title} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |