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
|
.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,
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
Reference in New Issue
Block a user