feat(ui): add mobile preview device framing and design QA tools
This commit is contained in:
@@ -32,6 +32,8 @@ export default function PreviewTab() {
|
||||
const bridge = usePreviewBridge();
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const [deviceMode, setDeviceMode] = useState<"desktop" | "mobile">("desktop");
|
||||
|
||||
// Auto-select first preview on load
|
||||
useEffect(() => {
|
||||
if (!selectedUrl && options.length > 0) {
|
||||
@@ -50,8 +52,8 @@ export default function PreviewTab() {
|
||||
|
||||
return (
|
||||
<div style={canvas}>
|
||||
{options.length > 1 && (
|
||||
<div style={toolbar}>
|
||||
<div style={toolbar}>
|
||||
{options.length > 1 && (
|
||||
<select
|
||||
value={selectedUrl ?? ""}
|
||||
onChange={(e) => setSelectedUrl(e.target.value)}
|
||||
@@ -64,36 +66,96 @@ export default function PreviewTab() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div style={previewFrame}>
|
||||
{loading && !iframeSrc ? (
|
||||
<div style={loaderWrap}>
|
||||
<Loader2
|
||||
className="animate-spin"
|
||||
style={{ width: 22, height: 22, color: "#9c9590" }}
|
||||
/>
|
||||
</div>
|
||||
) : iframeSrc ? (
|
||||
<iframe
|
||||
key={iframeSrc}
|
||||
src={iframeSrc}
|
||||
title="Preview"
|
||||
ref={(el) => {
|
||||
iframeDomRef.current = el;
|
||||
bridge?.registerPreviewIframe(el, iframeSrc);
|
||||
}}
|
||||
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
|
||||
style={iframeStyle}
|
||||
{...(sandboxIframe(iframeSrc, origin)
|
||||
? { sandbox: SAME_ORIGIN_SANDBOX }
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<div style={loaderWrap}>
|
||||
<p style={emptyText}>No preview available</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
padding: 4,
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(26,26,26,0.12)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setDeviceMode("desktop")}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.8rem",
|
||||
background:
|
||||
deviceMode === "desktop" ? "rgba(0,0,0,0.06)" : "transparent",
|
||||
color: deviceMode === "desktop" ? "#000" : "#666",
|
||||
}}
|
||||
>
|
||||
Desktop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeviceMode("mobile")}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.8rem",
|
||||
background:
|
||||
deviceMode === "mobile" ? "rgba(0,0,0,0.06)" : "transparent",
|
||||
color: deviceMode === "mobile" ? "#000" : "#666",
|
||||
}}
|
||||
>
|
||||
Mobile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...(deviceMode === "desktop" ? desktopFrame : mobileFrame),
|
||||
transition: "all 0.3s ease",
|
||||
}}
|
||||
>
|
||||
{deviceMode === "mobile" && <MobileChrome />}
|
||||
|
||||
{loading && !iframeSrc ? (
|
||||
<div style={loaderWrap}>
|
||||
<Loader2
|
||||
className="animate-spin"
|
||||
style={{ width: 22, height: 22, color: "#9c9590" }}
|
||||
/>
|
||||
</div>
|
||||
) : iframeSrc ? (
|
||||
<iframe
|
||||
key={iframeSrc}
|
||||
src={iframeSrc}
|
||||
title="Preview"
|
||||
ref={(el) => {
|
||||
iframeDomRef.current = el;
|
||||
bridge?.registerPreviewIframe(el, iframeSrc);
|
||||
}}
|
||||
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
|
||||
style={{
|
||||
...iframeStyle,
|
||||
borderRadius: deviceMode === "mobile" ? 44 : 0,
|
||||
}}
|
||||
{...(sandboxIframe(iframeSrc, origin)
|
||||
? { sandbox: SAME_ORIGIN_SANDBOX }
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<div style={loaderWrap}>
|
||||
<p style={emptyText}>No preview available</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceMode === "mobile" && <div style={homeIndicator} aria-hidden />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -130,9 +192,10 @@ const select: React.CSSProperties = {
|
||||
color: "#1a1a1a",
|
||||
};
|
||||
|
||||
const previewFrame: React.CSSProperties = {
|
||||
const desktopFrame: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 14,
|
||||
@@ -143,6 +206,121 @@ const previewFrame: React.CSSProperties = {
|
||||
"0 1px 2px rgba(26, 26, 26, 0.04), 0 12px 40px rgba(26, 26, 26, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.85)",
|
||||
};
|
||||
|
||||
const mobileFrame: React.CSSProperties = {
|
||||
position: "relative",
|
||||
width: 390,
|
||||
height: 844,
|
||||
flexShrink: 0,
|
||||
borderRadius: 56,
|
||||
padding: 12,
|
||||
background: "linear-gradient(160deg, #2a2a2c 0%, #1a1a1c 50%, #0e0e10 100%)",
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(255,255,255,0.04) inset, 0 0 0 2px #000 inset, 0 28px 60px -12px rgba(0,0,0,0.45), 0 8px 20px -8px rgba(0,0,0,0.35)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const homeIndicator: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: 20,
|
||||
transform: "translateX(-50%)",
|
||||
width: 134,
|
||||
height: 5,
|
||||
background: "#1a1916",
|
||||
borderRadius: 999,
|
||||
opacity: 0.85,
|
||||
pointerEvents: "none",
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
function MobileChrome() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 22,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 124,
|
||||
height: 36,
|
||||
background: "#000",
|
||||
borderRadius: 999,
|
||||
zIndex: 15,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
height: 47,
|
||||
padding: "18px 26px 0",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.01em",
|
||||
color: "#1a1916",
|
||||
pointerEvents: "none",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<span>9:41</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<svg
|
||||
viewBox="0 0 17 11"
|
||||
style={{ width: 17, height: 11, fill: "currentColor" }}
|
||||
aria-hidden
|
||||
>
|
||||
<rect x="0" y="7" width="3" height="4" rx="0.6" />
|
||||
<rect x="4" y="5" width="3" height="6" rx="0.6" />
|
||||
<rect x="8" y="3" width="3" height="8" rx="0.6" />
|
||||
<rect x="12" y="0" width="3" height="11" rx="0.6" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 17 11"
|
||||
style={{ width: 17, height: 11, fill: "currentColor" }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M8.5 1.5C5.5 1.5 2.7 2.6 0.5 4.6L2 6.1C3.8 4.5 6.1 3.6 8.5 3.6c2.4 0 4.7 0.9 6.5 2.5l1.5-1.5c-2.2-2-5-3.1-8-3.1zM3.5 7.6L5 9.1c1-0.9 2.2-1.4 3.5-1.4 1.3 0 2.5 0.5 3.5 1.4l1.5-1.5c-1.4-1.3-3.1-2-5-2-1.9 0-3.6 0.7-5 2zM6.5 10.6l2 2 2-2c-0.5-0.5-1.2-0.8-2-0.8s-1.5 0.3-2 0.8z" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 25 11"
|
||||
style={{ width: 25, height: 11, fill: "currentColor" }}
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="21"
|
||||
height="10"
|
||||
rx="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.45"
|
||||
/>
|
||||
<rect
|
||||
x="22"
|
||||
y="3.5"
|
||||
width="1.5"
|
||||
height="4"
|
||||
rx="0.4"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.45"
|
||||
/>
|
||||
<rect x="2" y="2" width="18" height="7" rx="1.4" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const iframeStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
|
||||
Reference in New Issue
Block a user