feat(preview): add server/port dropdown to address bar

This commit is contained in:
2026-06-12 12:20:48 -07:00
parent 5ed10c4077
commit cd26dd807f
3 changed files with 134 additions and 43 deletions

View File

@@ -71,7 +71,8 @@ export default function PreviewTab() {
) )
.sort((a, b) => a.port - b.port); // sort ports ascending .sort((a, b) => a.port - b.port); // sort ports ascending
const [selectedPort, setSelectedPort] = useState<number>(3000); const selectedPort = usePreviewToolbarStore((s) => s.selectedPort);
const setSelectedPort = usePreviewToolbarStore((s) => s.setSelectedPort);
// Auto-select logic if selectedPort is not in validPreviews // Auto-select logic if selectedPort is not in validPreviews
useEffect(() => { useEffect(() => {
@@ -83,7 +84,7 @@ export default function PreviewTab() {
validPreviews.find((p) => p.port === 3000) ?? validPreviews[0]; validPreviews.find((p) => p.port === 3000) ?? validPreviews[0];
if (fallback) setSelectedPort(fallback.port); if (fallback) setSelectedPort(fallback.port);
} }
}, [validPreviews, selectedPort]); }, [validPreviews, selectedPort, setSelectedPort]);
// Derive the currently selected preview // Derive the currently selected preview
const activePreview = validPreviews.find((p) => p.port === selectedPort); const activePreview = validPreviews.find((p) => p.port === selectedPort);
@@ -232,27 +233,6 @@ export default function PreviewTab() {
return ( return (
<div style={canvas}> <div style={canvas}>
{validPreviews.length > 1 && (
<div style={portPickerWrap}>
{validPreviews.map((p) => (
<button
key={p.id}
onClick={() => setSelectedPort(p.port)}
style={
selectedPort === p.port ? portButtonActive : portButtonInactive
}
>
<div
style={p.state === "running" ? dotRunning : dotStarting}
className={p.state === "starting" ? "animate-pulse" : ""}
/>
{p.name && p.name !== `port-${p.port}`
? p.name
: `Port ${p.port}`}
</button>
))}
</div>
)}
<div <div
style={{ style={{
flex: 1, flex: 1,

View File

@@ -7,6 +7,8 @@ interface PreviewToolbarState {
triggerRefresh: () => void; triggerRefresh: () => void;
currentPath: string; currentPath: string;
setCurrentPath: (path: string) => void; setCurrentPath: (path: string) => void;
selectedPort: number;
setSelectedPort: (port: number) => void;
} }
export const usePreviewToolbarStore = create<PreviewToolbarState>((set) => ({ export const usePreviewToolbarStore = create<PreviewToolbarState>((set) => ({
@@ -16,4 +18,6 @@ export const usePreviewToolbarStore = create<PreviewToolbarState>((set) => ({
triggerRefresh: () => set((state) => ({ refreshKey: state.refreshKey + 1 })), triggerRefresh: () => set((state) => ({ refreshKey: state.refreshKey + 1 })),
currentPath: "/", currentPath: "/",
setCurrentPath: (path) => set({ currentPath: path }), setCurrentPath: (path) => set({ currentPath: path }),
selectedPort: 3000,
setSelectedPort: (port) => set({ selectedPort: port }),
})); }));

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useParams } from "next/navigation"; import { usePathname, useParams } from "next/navigation";
import { import {
@@ -8,6 +9,7 @@ import {
Smartphone, Smartphone,
RotateCw, RotateCw,
ExternalLink, ExternalLink,
ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { usePreviewToolbarStore } from "./preview-toolbar/preview-toolbar-state"; import { usePreviewToolbarStore } from "./preview-toolbar/preview-toolbar-state";
import { useAnatomy } from "@/components/project/use-anatomy"; import { useAnatomy } from "@/components/project/use-anatomy";
@@ -94,13 +96,32 @@ function PreviewDeviceToggles() {
const triggerRefresh = usePreviewToolbarStore((s) => s.triggerRefresh); const triggerRefresh = usePreviewToolbarStore((s) => s.triggerRefresh);
const currentPath = usePreviewToolbarStore((s) => s.currentPath); const currentPath = usePreviewToolbarStore((s) => s.currentPath);
const setCurrentPath = usePreviewToolbarStore((s) => s.setCurrentPath); const setCurrentPath = usePreviewToolbarStore((s) => s.setCurrentPath);
const selectedPort = usePreviewToolbarStore((s) => s.selectedPort);
const setSelectedPort = usePreviewToolbarStore((s) => s.setSelectedPort);
const params = useParams(); const params = useParams();
const projectId = params?.projectId as string; const projectId = params?.projectId as string;
const { anatomy } = useAnatomy(projectId); const { anatomy } = useAnatomy(projectId);
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 10000);
return () => clearInterval(id);
}, []);
const previews = anatomy?.hosting.previews ?? []; const previews = anatomy?.hosting.previews ?? [];
const running = previews.find((p) => p.state === "running"); 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 activePreview = validPreviews.find((p) => p.port === selectedPort);
const running =
activePreview?.state === "running" ? activePreview : undefined;
const displayUrl = running?.url ?? ""; const displayUrl = running?.url ?? "";
return ( return (
@@ -159,6 +180,63 @@ function PreviewDeviceToggles() {
}} }}
/> />
{/* Server Selection Dropdown */}
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
flexShrink: 0,
}}
>
<select
value={selectedPort}
onChange={(e) => setSelectedPort(Number(e.target.value))}
style={{
background: "transparent",
border: "none",
outline: "none",
color: "#18181b",
fontSize: "0.75rem",
fontWeight: 500,
cursor: "pointer",
appearance: "none",
paddingRight: 16,
paddingLeft: 4,
}}
>
{validPreviews.map((p) => (
<option key={p.id} value={p.port}>
{p.name && p.name !== `port-${p.port}`
? p.name
: `Port ${p.port}`}
</option>
))}
{validPreviews.length === 0 && (
<option value={3000}>No running server</option>
)}
</select>
<ChevronDown
size={12}
style={{
color: "#71717a",
position: "absolute",
right: 2,
pointerEvents: "none",
}}
/>
</div>
<div
style={{
width: 1,
height: 14,
background: "#e4e4e7",
margin: "0 4px",
flexShrink: 0,
}}
/>
<span <span
style={{ style={{
opacity: 0.5, opacity: 0.5,
@@ -169,30 +247,59 @@ function PreviewDeviceToggles() {
> >
/ /
</span> </span>
<input <div
type="text"
value={currentPath === "/" ? "" : currentPath.replace(/^\//, "")}
onChange={(e) =>
setCurrentPath("/" + e.target.value.replace(/^\//, ""))
}
placeholder="path (e.g. dashboard)"
style={{ style={{
background: "transparent",
border: "none",
outline: "none",
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
color: "#18181b", position: "relative",
fontSize: "0.75rem", display: "flex",
textOverflow: "ellipsis", alignItems: "center",
fontFamily: "var(--font-mono), monospace",
}} }}
onKeyDown={(e) => { >
if (e.key === "Enter") { <input
triggerRefresh(); // force reload iframe type="text"
list="common-routes"
value={currentPath === "/" ? "" : currentPath.replace(/^\//, "")}
onChange={(e) =>
setCurrentPath("/" + e.target.value.replace(/^\//, ""))
} }
}} placeholder="path (e.g. dashboard)"
/> style={{
background: "transparent",
border: "none",
outline: "none",
width: "100%",
color: "#18181b",
fontSize: "0.75rem",
textOverflow: "ellipsis",
fontFamily: "var(--font-mono), monospace",
paddingRight: 16,
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
triggerRefresh(); // force reload iframe
}
}}
/>
<datalist id="common-routes">
<option value="dashboard" />
<option value="login" />
<option value="signup" />
<option value="about" />
<option value="contact" />
<option value="pricing" />
<option value="settings" />
</datalist>
<ChevronDown
size={12}
style={{
color: "#71717a",
position: "absolute",
right: 4,
pointerEvents: "none",
}}
/>
</div>
<div <div
style={{ style={{