feat(preview): add server/port dropdown to address bar
This commit is contained in:
@@ -71,7 +71,8 @@ export default function PreviewTab() {
|
||||
)
|
||||
.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
|
||||
useEffect(() => {
|
||||
@@ -83,7 +84,7 @@ export default function PreviewTab() {
|
||||
validPreviews.find((p) => p.port === 3000) ?? validPreviews[0];
|
||||
if (fallback) setSelectedPort(fallback.port);
|
||||
}
|
||||
}, [validPreviews, selectedPort]);
|
||||
}, [validPreviews, selectedPort, setSelectedPort]);
|
||||
|
||||
// Derive the currently selected preview
|
||||
const activePreview = validPreviews.find((p) => p.port === selectedPort);
|
||||
@@ -232,27 +233,6 @@ export default function PreviewTab() {
|
||||
|
||||
return (
|
||||
<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
|
||||
style={{
|
||||
flex: 1,
|
||||
|
||||
@@ -7,6 +7,8 @@ interface PreviewToolbarState {
|
||||
triggerRefresh: () => void;
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
selectedPort: number;
|
||||
setSelectedPort: (port: number) => void;
|
||||
}
|
||||
|
||||
export const usePreviewToolbarStore = create<PreviewToolbarState>((set) => ({
|
||||
@@ -16,4 +18,6 @@ export const usePreviewToolbarStore = create<PreviewToolbarState>((set) => ({
|
||||
triggerRefresh: () => set((state) => ({ refreshKey: state.refreshKey + 1 })),
|
||||
currentPath: "/",
|
||||
setCurrentPath: (path) => set({ currentPath: path }),
|
||||
selectedPort: 3000,
|
||||
setSelectedPort: (port) => set({ selectedPort: port }),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useParams } from "next/navigation";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Smartphone,
|
||||
RotateCw,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { usePreviewToolbarStore } from "./preview-toolbar/preview-toolbar-state";
|
||||
import { useAnatomy } from "@/components/project/use-anatomy";
|
||||
@@ -94,13 +96,32 @@ function PreviewDeviceToggles() {
|
||||
const triggerRefresh = usePreviewToolbarStore((s) => s.triggerRefresh);
|
||||
const currentPath = usePreviewToolbarStore((s) => s.currentPath);
|
||||
const setCurrentPath = usePreviewToolbarStore((s) => s.setCurrentPath);
|
||||
const selectedPort = usePreviewToolbarStore((s) => s.selectedPort);
|
||||
const setSelectedPort = usePreviewToolbarStore((s) => s.setSelectedPort);
|
||||
|
||||
const params = useParams();
|
||||
const projectId = params?.projectId as string;
|
||||
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 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 ?? "";
|
||||
|
||||
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
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
@@ -169,30 +247,59 @@ function PreviewDeviceToggles() {
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={currentPath === "/" ? "" : currentPath.replace(/^\//, "")}
|
||||
onChange={(e) =>
|
||||
setCurrentPath("/" + e.target.value.replace(/^\//, ""))
|
||||
}
|
||||
placeholder="path (e.g. dashboard)"
|
||||
<div
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: "#18181b",
|
||||
fontSize: "0.75rem",
|
||||
textOverflow: "ellipsis",
|
||||
fontFamily: "var(--font-mono), monospace",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
triggerRefresh(); // force reload iframe
|
||||
>
|
||||
<input
|
||||
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
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user