The dominant production failure was a dead dev-server process behind a 'running' DB flag (idle-stop / OOM / crash / host restart), which the UI trusted and embedded -> permanent 502 until a manual restart. - dev-container.ts: add isDevServerListening() fast liveness probe; stop the container entrypoint from auto-running 'npx next dev --webpack' (it competed with the managed server, forced the wrong bundler/cwd, and doubled memory); drop the fake state='running' seed row; bump dev container memory 1g -> 2g. - ensure route: verify a 'running' row is ACTUALLY listening and resurrect it if dead, instead of trusting the flag; never bounce a healthy server. - preview page: call ensure on every mount and on refresh (verify + heal), force an immediate anatomy refetch on (re)start so a dead frame swaps to 'warming up' without the 5s lag. Backstopped by the partial unique index + startDevServer idempotency, so heals can never duplicate or thrash a server.
733 lines
21 KiB
TypeScript
733 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useParams } from "next/navigation";
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import { useAnatomy } from "@/components/project/use-anatomy";
|
|
import { usePreviewBridge } from "@/components/project/preview-bridge-context";
|
|
import { usePreviewToolbarStore } from "@/components/project/preview-toolbar/preview-toolbar-state";
|
|
|
|
const SAME_ORIGIN_SANDBOX =
|
|
"allow-scripts allow-forms allow-same-origin allow-popups allow-modals allow-downloads" as const;
|
|
|
|
function sandboxIframe(src: string, origin: string): boolean {
|
|
if (!src.startsWith("http://") && !src.startsWith("https://")) return true;
|
|
try {
|
|
return origin.length > 0 && new URL(src).origin === origin;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** Elapsed time since an ISO string, formatted as "1m 23s". Capped at 59m 59s. */
|
|
function useElapsed(sinceIso: string | undefined) {
|
|
const [elapsed, setElapsed] = useState("");
|
|
useEffect(() => {
|
|
if (!sinceIso) return;
|
|
const update = () => {
|
|
const ms = Date.now() - new Date(sinceIso).getTime();
|
|
if (ms < 0) return;
|
|
const s = Math.floor(ms / 1000);
|
|
const m = Math.floor(s / 60);
|
|
if (m > 59) {
|
|
setElapsed("> 1h");
|
|
} else {
|
|
setElapsed(m > 0 ? `${m}m ${s % 60}s` : `${s}s`);
|
|
}
|
|
};
|
|
update();
|
|
const id = setInterval(update, 1000);
|
|
return () => {
|
|
clearInterval(id);
|
|
setElapsed("");
|
|
};
|
|
}, [sinceIso]);
|
|
return elapsed;
|
|
}
|
|
|
|
export default function PreviewTab() {
|
|
const params = useParams();
|
|
const projectId = params.projectId as string;
|
|
|
|
// Poll every 5s so state transitions (starting→running, build complete, etc.)
|
|
// surface without a manual refresh.
|
|
const { anatomy, loading, reload } = useAnatomy(projectId, { pollMs: 5000 });
|
|
|
|
const previews = anatomy?.hosting.previews ?? [];
|
|
|
|
const [now, setNow] = useState<number>(() => Date.now());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 10000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// Identify all valid running/starting previews (ignoring stale starting ones)
|
|
const validPreviews = previews
|
|
.filter(
|
|
(p) =>
|
|
p.state === "running" ||
|
|
(p.state === "starting" &&
|
|
now - new Date(p.startedAt).getTime() < 15 * 60 * 1000),
|
|
)
|
|
.sort((a, b) => a.port - b.port); // sort ports ascending
|
|
|
|
const selectedPort = usePreviewToolbarStore((s) => s.selectedPort);
|
|
const setSelectedPort = usePreviewToolbarStore((s) => s.setSelectedPort);
|
|
|
|
// Auto-select logic if selectedPort is not in validPreviews
|
|
useEffect(() => {
|
|
if (validPreviews.length === 0) return;
|
|
const hasSelected = validPreviews.some((p) => p.port === selectedPort);
|
|
if (!hasSelected) {
|
|
// Prefer 3000 if available, else pick the first one
|
|
const fallback =
|
|
validPreviews.find((p) => p.port === 3000) ?? validPreviews[0];
|
|
if (fallback) setSelectedPort(fallback.port);
|
|
}
|
|
}, [validPreviews, selectedPort, setSelectedPort]);
|
|
|
|
// Derive the currently selected preview
|
|
const activePreview = validPreviews.find((p) => p.port === selectedPort);
|
|
|
|
// Split it into running and starting like before
|
|
const primaryRunning =
|
|
activePreview?.state === "running" ? activePreview : undefined;
|
|
const primaryStarting =
|
|
activePreview?.state === "starting" ? activePreview : undefined;
|
|
|
|
// Derive in-flight / recently-failed build from prod apps.
|
|
const liveApps = anatomy?.hosting.live ?? [];
|
|
const inFlightApp = liveApps.find((a) => a.inFlightBuild);
|
|
const failedApp = !inFlightApp
|
|
? liveApps.find((a) => a.lastBuild?.status === "failed")
|
|
: undefined;
|
|
|
|
// Fallback URL — the last deployed production app. Shown as a link while the
|
|
// dev server is warming up so the user has something to interact with.
|
|
const fallbackFqdn =
|
|
liveApps.find((a) => a.fqdn && a.status === "running")?.fqdn ?? null;
|
|
const fallbackUrl = fallbackFqdn
|
|
? fallbackFqdn.startsWith("http")
|
|
? fallbackFqdn
|
|
: `https://${fallbackFqdn}`
|
|
: null;
|
|
|
|
// ── Auto-ensure: the single entry point that guarantees the preview is live.
|
|
// We call it on every mount — even when anatomy already says "running" —
|
|
// because a `running` row is only intent; the process may have died
|
|
// (idle-stop / OOM / crash / host restart) leaving a dead port behind a
|
|
// stale flag. The `ensure` endpoint verifies the port is ACTUALLY answering
|
|
// and resurrects it if not, but never bounces a healthy server. That makes
|
|
// "open the preview → it loads cleanly" reliable, and keeps the container
|
|
// warm (the liveness probe touches activity).
|
|
const ensureCalledRef = useRef(false);
|
|
const [ensureStatus, setEnsureStatus] = useState<
|
|
"idle" | "calling" | "starting" | "running" | "no_history" | "error"
|
|
>("idle");
|
|
|
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
|
const iframeDomRef = useRef<HTMLIFrameElement | null>(null);
|
|
const bridge = usePreviewBridge();
|
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
|
|
const deviceMode = usePreviewToolbarStore((s) => s.deviceMode);
|
|
const refreshKey = usePreviewToolbarStore((s) => s.refreshKey);
|
|
const currentPath = usePreviewToolbarStore((s) => s.currentPath);
|
|
|
|
const [isForceStarting, setIsForceStarting] = useState(false);
|
|
|
|
// Auto-ensure + refresh-heal, in one effect.
|
|
//
|
|
// On mount (and whenever the refresh button bumps `refreshKey`) we hit the
|
|
// `ensure` endpoint, which is the single entry point that guarantees the
|
|
// preview is live. We call it even when anatomy already says "running",
|
|
// because a `running` row is only intent — the process may have died
|
|
// (idle-stop / OOM / crash / host restart) leaving a dead port behind a stale
|
|
// flag. `ensure` verifies the port is ACTUALLY answering and resurrects it if
|
|
// not, but never bounces a healthy server (and the unique index +
|
|
// `startDevServer` idempotency mean it can't duplicate one). So "open the
|
|
// preview" and "click refresh" both reliably land on a clean, loaded app.
|
|
//
|
|
// The re-arm is a ref write (not setState), so the effect body stays free of
|
|
// synchronous state updates; the only setState calls live in async callbacks.
|
|
const lastEnsuredRefreshKeyRef = useRef(refreshKey);
|
|
useEffect(() => {
|
|
if (refreshKey !== lastEnsuredRefreshKeyRef.current && !isForceStarting) {
|
|
lastEnsuredRefreshKeyRef.current = refreshKey;
|
|
ensureCalledRef.current = false;
|
|
}
|
|
|
|
if (ensureCalledRef.current) return;
|
|
if (loading || !anatomy) return;
|
|
ensureCalledRef.current = true;
|
|
|
|
fetch(`/api/projects/${projectId}/dev-server/ensure`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
})
|
|
.then((r) => r.json())
|
|
.then((data: { status?: string }) => {
|
|
if (data.status === "no_history" || data.status === "no_container") {
|
|
setEnsureStatus("no_history");
|
|
} else if (data.status === "running") {
|
|
// Verified live — keep showing the iframe.
|
|
setEnsureStatus("running");
|
|
} else if (data.status === "starting") {
|
|
// Fresh start or resurrection of a dead server. Flip to warming-up and
|
|
// force an immediate anatomy refetch: `ensure` has already marked any
|
|
// stale/dead `running` row as stopped, so the refetch drops the
|
|
// possibly-502 iframe and shows warming-up without waiting for the 5s
|
|
// poll. The readiness probe then carries it to a clean load.
|
|
setEnsureStatus("starting");
|
|
reload();
|
|
} else {
|
|
setEnsureStatus("idle");
|
|
}
|
|
})
|
|
.catch(() => setEnsureStatus("error"));
|
|
}, [loading, anatomy, projectId, refreshKey, isForceStarting, reload]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!primaryRunning?.url) {
|
|
setIframeSrc(null);
|
|
} else {
|
|
const base = primaryRunning.url.replace(/\/$/, "");
|
|
const path = currentPath.startsWith("/")
|
|
? currentPath
|
|
: `/${currentPath}`;
|
|
setIframeSrc(`${base}${path}`);
|
|
}
|
|
}, [primaryRunning?.url, currentPath]);
|
|
|
|
useEffect(() => {
|
|
if (!bridge || !iframeSrc || !iframeDomRef.current) return;
|
|
bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc);
|
|
}, [bridge, iframeSrc]);
|
|
|
|
// Determine which empty state to show.
|
|
const emptyContent = (() => {
|
|
if (loading && !anatomy) return <InitialLoader />;
|
|
if (inFlightApp) return <BuildingState app={inFlightApp} />;
|
|
if (failedApp) return <FailedState app={failedApp} />;
|
|
|
|
// Dev server is in the process of booting (either picked up from anatomy
|
|
// or we just fired the ensure endpoint and are waiting for the DB row).
|
|
// If isForceStarting is true, we know we clicked the manual start button
|
|
// and are waiting for the DB to reflect 'starting'.
|
|
if (primaryStarting || ensureStatus === "starting" || isForceStarting) {
|
|
return (
|
|
<WarmingUpState
|
|
startedAt={primaryStarting?.startedAt}
|
|
fallbackUrl={fallbackUrl}
|
|
port={selectedPort}
|
|
/>
|
|
);
|
|
}
|
|
if (ensureStatus === "calling") {
|
|
return (
|
|
<WarmingUpState
|
|
startedAt={undefined}
|
|
fallbackUrl={fallbackUrl}
|
|
port={selectedPort}
|
|
/>
|
|
);
|
|
}
|
|
// Never had a dev server — needs the AI to start one.
|
|
return (
|
|
<NotRunningState
|
|
onStart={() => {
|
|
setIsForceStarting(true);
|
|
fetch(
|
|
`/api/projects/${projectId}/dev-server/ensure?forceStart=true`,
|
|
{
|
|
method: "POST",
|
|
},
|
|
)
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error("Failed to start");
|
|
})
|
|
.catch(() => setIsForceStarting(false));
|
|
}}
|
|
/>
|
|
);
|
|
})();
|
|
|
|
return (
|
|
<div style={canvas}>
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
...(deviceMode === "desktop"
|
|
? desktopFrame
|
|
: deviceMode === "tablet"
|
|
? tabletFrame
|
|
: mobileFrame),
|
|
transition: "all 0.3s ease",
|
|
transform:
|
|
deviceMode === "tablet"
|
|
? "scale(0.85)"
|
|
: deviceMode === "mobile"
|
|
? "scale(0.95)"
|
|
: "none",
|
|
transformOrigin: "center center",
|
|
}}
|
|
>
|
|
{deviceMode !== "desktop" && <MobileChrome />}
|
|
|
|
{iframeSrc ? (
|
|
<iframe
|
|
key={`${iframeSrc}-${refreshKey}`}
|
|
src={iframeSrc}
|
|
title="Preview"
|
|
ref={(el) => {
|
|
iframeDomRef.current = el;
|
|
if (el) bridge?.registerPreviewIframe(el, iframeSrc);
|
|
}}
|
|
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
|
|
style={{
|
|
...iframeStyle,
|
|
borderRadius:
|
|
deviceMode === "mobile"
|
|
? 44
|
|
: deviceMode === "tablet"
|
|
? 28
|
|
: 0,
|
|
}}
|
|
{...(sandboxIframe(iframeSrc, origin)
|
|
? { sandbox: SAME_ORIGIN_SANDBOX }
|
|
: {})}
|
|
/>
|
|
) : (
|
|
<div style={loaderWrap}>{emptyContent}</div>
|
|
)}
|
|
|
|
{deviceMode !== "desktop" && (
|
|
<div style={homeIndicator} aria-hidden />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Empty-state sub-components ────────────────────────────────────────────────
|
|
|
|
function InitialLoader() {
|
|
return (
|
|
<Loader2
|
|
className="animate-spin"
|
|
style={{ width: 22, height: 22, color: "#9c9590" }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function WarmingUpState({
|
|
startedAt,
|
|
fallbackUrl,
|
|
port,
|
|
}: {
|
|
startedAt: string | undefined;
|
|
fallbackUrl: string | null;
|
|
port?: number;
|
|
}) {
|
|
const elapsed = useElapsed(startedAt);
|
|
|
|
return (
|
|
<div style={{ textAlign: "center", maxWidth: 280 }}>
|
|
<div
|
|
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
|
|
>
|
|
<div style={buildRingOuter}>
|
|
<div style={buildRingInner} className="animate-spin" />
|
|
</div>
|
|
</div>
|
|
|
|
<p style={{ ...emptyTitle, color: "#6366f1" }}>
|
|
Dev server {port ? `(:${port}) ` : ""}warming up
|
|
{elapsed ? ` · ${elapsed}` : ""}
|
|
</p>
|
|
<p style={emptySubtext}>
|
|
Your preview will appear here automatically once it's ready.
|
|
Usually under 15 seconds.
|
|
</p>
|
|
|
|
{fallbackUrl && (
|
|
<a
|
|
href={fallbackUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
display: "inline-block",
|
|
marginTop: 14,
|
|
padding: "5px 12px",
|
|
borderRadius: 20,
|
|
background: "#f5f3ff",
|
|
border: "1px solid #c4b5fd",
|
|
fontSize: "0.72rem",
|
|
color: "#7c3aed",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
View last deployed version →
|
|
</a>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BuildingState({
|
|
app,
|
|
}: {
|
|
app: {
|
|
name: string;
|
|
inFlightBuild?: { status: string; createdAt?: string; finishedAt?: string };
|
|
};
|
|
}) {
|
|
const elapsed = useElapsed(app.inFlightBuild?.createdAt);
|
|
|
|
return (
|
|
<div style={{ textAlign: "center", maxWidth: 280 }}>
|
|
<div
|
|
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
|
|
>
|
|
<div style={buildRingOuter}>
|
|
<div style={buildRingInner} className="animate-spin" />
|
|
</div>
|
|
</div>
|
|
|
|
<p style={{ ...emptyTitle, color: "#3b82f6" }}>Building {app.name}</p>
|
|
<p style={emptySubtext}>
|
|
Your app is compiling and deploying. The preview will load automatically
|
|
when it's ready.
|
|
</p>
|
|
|
|
<div style={statusPill}>
|
|
<span
|
|
style={{
|
|
display: "inline-block",
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: "50%",
|
|
background: "#3b82f6",
|
|
marginRight: 6,
|
|
verticalAlign: "middle",
|
|
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
|
|
}}
|
|
/>
|
|
<span style={{ verticalAlign: "middle" }}>
|
|
{app.inFlightBuild?.status ?? "building"}
|
|
{elapsed ? ` · ${elapsed}` : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FailedState({
|
|
app,
|
|
}: {
|
|
app: { name: string; lastBuild?: { status: string; commit?: string } };
|
|
}) {
|
|
return (
|
|
<div style={{ textAlign: "center", maxWidth: 280 }}>
|
|
<div
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: "50%",
|
|
background: "#fef2f2",
|
|
border: "1.5px solid #fecaca",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
margin: "0 auto 14px",
|
|
fontSize: 18,
|
|
}}
|
|
>
|
|
✕
|
|
</div>
|
|
|
|
<p style={{ ...emptyTitle, color: "#ef4444" }}>Build failed</p>
|
|
<p style={emptySubtext}>
|
|
The last deploy of <strong>{app.name}</strong> didn't succeed.
|
|
{app.lastBuild?.commit ? ` (${app.lastBuild.commit.slice(0, 7)})` : ""}
|
|
</p>
|
|
<p style={{ ...emptySubtext, marginTop: 8 }}>
|
|
Ask the AI to check the build logs and fix the issue.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NotRunningState({ onStart }: { onStart: () => void }) {
|
|
return (
|
|
<div style={{ textAlign: "center", maxWidth: 260 }}>
|
|
<div
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: "50%",
|
|
background: "#f4f4f5",
|
|
border: "1.5px solid #e4e4e7",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
margin: "0 auto 14px",
|
|
fontSize: 18,
|
|
color: "#a1a1aa",
|
|
}}
|
|
>
|
|
⏸
|
|
</div>
|
|
<p style={emptyTitle}>Preview not running</p>
|
|
<p style={emptySubtext}>No dev server found on port 3000.</p>
|
|
<button
|
|
onClick={onStart}
|
|
style={{
|
|
marginTop: 16,
|
|
background: "#18181b",
|
|
color: "white",
|
|
border: "none",
|
|
padding: "8px 16px",
|
|
borderRadius: 8,
|
|
fontSize: "0.8rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
⚡ Start Preview
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
|
|
const portPickerWrap: React.CSSProperties = {
|
|
display: "flex",
|
|
gap: 8,
|
|
marginBottom: 12,
|
|
padding: "0 4px",
|
|
flexWrap: "wrap",
|
|
};
|
|
|
|
const portButtonBase: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "4px 12px",
|
|
borderRadius: 20,
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
transition: "all 0.15s ease",
|
|
border: "1px solid transparent",
|
|
};
|
|
|
|
const portButtonActive: React.CSSProperties = {
|
|
...portButtonBase,
|
|
background: "#fff",
|
|
borderColor: "rgba(26, 26, 26, 0.08)",
|
|
color: "#18181b",
|
|
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
|
|
};
|
|
|
|
const portButtonInactive: React.CSSProperties = {
|
|
...portButtonBase,
|
|
background: "transparent",
|
|
color: "#71717a",
|
|
border: "1px solid transparent",
|
|
};
|
|
|
|
const dotBase: React.CSSProperties = {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: "50%",
|
|
};
|
|
|
|
const dotRunning: React.CSSProperties = {
|
|
...dotBase,
|
|
background: "#10b981", // green
|
|
boxShadow: "0 0 6px rgba(16, 185, 129, 0.4)",
|
|
};
|
|
|
|
const dotStarting: React.CSSProperties = {
|
|
...dotBase,
|
|
background: "#6366f1", // indigo
|
|
boxShadow: "0 0 6px rgba(99, 102, 241, 0.4)",
|
|
};
|
|
|
|
const canvas: React.CSSProperties = {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
width: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignSelf: "stretch",
|
|
boxSizing: "border-box",
|
|
padding: "14px 16px 18px",
|
|
background: "linear-gradient(165deg, #faf8f5 0%, #f4f0ea 42%, #ebe7df 100%)",
|
|
};
|
|
|
|
const desktopFrame: React.CSSProperties = {
|
|
flex: 1,
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
borderRadius: 14,
|
|
overflow: "hidden",
|
|
background: "#fff",
|
|
border: "1px solid rgba(26, 26, 26, 0.07)",
|
|
boxShadow:
|
|
"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 tabletFrame: React.CSSProperties = {
|
|
position: "relative",
|
|
width: 768,
|
|
height: 1024,
|
|
flexShrink: 0,
|
|
borderRadius: 40,
|
|
overflow: "hidden",
|
|
background: "#000",
|
|
border: "14px solid #1a1a1a",
|
|
boxShadow:
|
|
"0 0 0 1px rgba(255,255,255,0.1) inset, 0 24px 60px rgba(26, 26, 26, 0.15)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|
|
|
|
const mobileFrame: React.CSSProperties = {
|
|
position: "relative",
|
|
width: 390,
|
|
height: 844,
|
|
flexShrink: 0,
|
|
borderRadius: 56,
|
|
overflow: "hidden",
|
|
background: "#000",
|
|
border: "14px solid #1a1a1a",
|
|
boxShadow:
|
|
"0 0 0 1px rgba(255,255,255,0.1) inset, 0 24px 60px rgba(26, 26, 26, 0.15)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|
|
|
|
const iframeStyle: React.CSSProperties = {
|
|
flex: 1,
|
|
width: "100%",
|
|
height: "100%",
|
|
border: "none",
|
|
background: "#fff",
|
|
pointerEvents: "auto",
|
|
};
|
|
|
|
const loaderWrap: React.CSSProperties = {
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: "#fff",
|
|
};
|
|
|
|
const emptyTitle: React.CSSProperties = {
|
|
margin: "0 0 6px",
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
color: "#18181b",
|
|
letterSpacing: "-0.01em",
|
|
};
|
|
|
|
const emptySubtext: React.CSSProperties = {
|
|
margin: 0,
|
|
fontSize: "0.78rem",
|
|
color: "#71717a",
|
|
lineHeight: 1.5,
|
|
};
|
|
|
|
const statusPill: React.CSSProperties = {
|
|
display: "inline-block",
|
|
marginTop: 14,
|
|
padding: "4px 10px",
|
|
borderRadius: 20,
|
|
background: "#eff6ff",
|
|
border: "1px solid #bfdbfe",
|
|
fontSize: "0.72rem",
|
|
color: "#1d4ed8",
|
|
};
|
|
|
|
const buildRingOuter: React.CSSProperties = {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: "50%",
|
|
background: "#eff6ff",
|
|
border: "2px solid #bfdbfe",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
};
|
|
|
|
const buildRingInner: React.CSSProperties = {
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: "50%",
|
|
borderTop: "2.5px solid #3b82f6",
|
|
borderRight: "2.5px solid transparent",
|
|
borderBottom: "2.5px solid transparent",
|
|
borderLeft: "2.5px solid transparent",
|
|
};
|
|
|
|
function MobileChrome() {
|
|
return (
|
|
<div style={notchWrap}>
|
|
<div style={notch} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const notchWrap: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 32,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
zIndex: 10,
|
|
pointerEvents: "none",
|
|
};
|
|
|
|
const notch: React.CSSProperties = {
|
|
width: 120,
|
|
height: 30,
|
|
background: "#1a1a1a",
|
|
borderBottomLeftRadius: 20,
|
|
borderBottomRightRadius: 20,
|
|
};
|
|
|
|
const homeIndicator: React.CSSProperties = {
|
|
position: "absolute",
|
|
bottom: 8,
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
width: 140,
|
|
height: 5,
|
|
borderRadius: 10,
|
|
background: "rgba(255, 255, 255, 0.4)",
|
|
mixBlendMode: "difference",
|
|
zIndex: 10,
|
|
pointerEvents: "none",
|
|
};
|