428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname, useParams } from "next/navigation";
|
|
import {
|
|
Monitor,
|
|
Tablet,
|
|
Smartphone,
|
|
RotateCw,
|
|
ExternalLink,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
import { usePreviewToolbarStore } from "./preview-toolbar/preview-toolbar-state";
|
|
import { useAnatomy } from "@/components/project/use-anatomy";
|
|
|
|
interface Props {
|
|
workspace: string;
|
|
projectId: string;
|
|
actions?: React.ReactNode;
|
|
}
|
|
|
|
export function ProjectIconRail({ workspace, projectId, actions }: Props) {
|
|
const pathname = usePathname() ?? "";
|
|
const projectBase = `/${workspace}/project/${projectId}`;
|
|
const isPreviewActive =
|
|
pathname === `${projectBase}/preview` ||
|
|
pathname.startsWith(`${projectBase}/preview/`);
|
|
|
|
return (
|
|
<nav style={bar} aria-label="Project sections">
|
|
<div style={primaryGroup}>
|
|
<Link
|
|
href={`${projectBase}/preview`}
|
|
style={{
|
|
padding: "4px 12px",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
borderRadius: 6,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
height: 24, // Explicitly match device toggles height
|
|
textDecoration: "none",
|
|
background: isPreviewActive ? "#ffffff" : "transparent",
|
|
color: isPreviewActive ? "#18181b" : "#71717a",
|
|
boxShadow: isPreviewActive ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
|
|
transition: "all 0.15s ease",
|
|
}}
|
|
>
|
|
Preview
|
|
</Link>
|
|
<Link
|
|
href={`${projectBase}/plan`}
|
|
style={{
|
|
padding: "4px 12px",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
borderRadius: 6,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
height: 24, // Explicitly match device toggles height
|
|
textDecoration: "none",
|
|
background: !isPreviewActive ? "#ffffff" : "transparent",
|
|
color: !isPreviewActive ? "#18181b" : "#71717a",
|
|
boxShadow: !isPreviewActive ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
|
|
transition: "all 0.15s ease",
|
|
}}
|
|
>
|
|
Dashboard
|
|
</Link>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
minWidth: 0,
|
|
marginLeft: 12,
|
|
}}
|
|
>
|
|
{isPreviewActive && <PreviewDeviceToggles />}
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flexShrink: 0,
|
|
marginLeft: 12,
|
|
}}
|
|
>
|
|
{actions}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function PreviewDeviceToggles() {
|
|
const deviceMode = usePreviewToolbarStore((s) => s.deviceMode);
|
|
const setDeviceMode = usePreviewToolbarStore((s) => s.setDeviceMode);
|
|
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 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 (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
background: "#fafafa",
|
|
border: "1px solid #e4e4e7",
|
|
padding: "4px 8px",
|
|
borderRadius: 8,
|
|
height: 34,
|
|
flex: 1,
|
|
minWidth: 0, // critical for input overflow to work
|
|
color: "#71717a",
|
|
fontSize: "0.75rem",
|
|
}}
|
|
>
|
|
<button
|
|
onClick={triggerRefresh}
|
|
title="Reload Preview"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 4,
|
|
border: "none",
|
|
cursor: "pointer",
|
|
transition: "all 0.15s",
|
|
background: "transparent",
|
|
color: "#71717a",
|
|
flexShrink: 0,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = "#e4e4e7";
|
|
e.currentTarget.style.color = "#18181b";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = "transparent";
|
|
e.currentTarget.style.color = "#71717a";
|
|
}}
|
|
>
|
|
<RotateCw size={12} />
|
|
</button>
|
|
|
|
<div
|
|
style={{
|
|
width: 1,
|
|
height: 14,
|
|
background: "#e4e4e7",
|
|
margin: "0 2px",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
|
|
{/* 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,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
position: "relative",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<select
|
|
value={currentPath}
|
|
onChange={(e) => setCurrentPath(e.target.value)}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
outline: "none",
|
|
width: "100%",
|
|
color: "#18181b",
|
|
fontSize: "0.75rem",
|
|
textOverflow: "ellipsis",
|
|
fontFamily: "var(--font-mono), monospace",
|
|
paddingRight: 16,
|
|
appearance: "none",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<option value="/">/</option>
|
|
<option value="/dashboard">/dashboard</option>
|
|
<option value="/login">/login</option>
|
|
<option value="/signup">/signup</option>
|
|
<option value="/about">/about</option>
|
|
<option value="/contact">/contact</option>
|
|
<option value="/pricing">/pricing</option>
|
|
<option value="/settings">/settings</option>
|
|
</select>
|
|
<ChevronDown
|
|
size={12}
|
|
style={{
|
|
color: "#71717a",
|
|
position: "absolute",
|
|
right: 4,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
width: 1,
|
|
height: 14,
|
|
background: "#e4e4e7",
|
|
margin: "0 4px",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
|
|
{/* Device Toggles Inside the Address Bar */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 2,
|
|
alignItems: "center",
|
|
marginRight: 4,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => setDeviceMode("desktop")}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 4,
|
|
border: "none",
|
|
cursor: "pointer",
|
|
transition: "all 0.15s",
|
|
background: deviceMode === "desktop" ? "#e4e4e7" : "transparent",
|
|
color: deviceMode === "desktop" ? "#18181b" : "#71717a",
|
|
}}
|
|
title="Desktop view"
|
|
>
|
|
<Monitor size={12} />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeviceMode("tablet")}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 4,
|
|
border: "none",
|
|
cursor: "pointer",
|
|
transition: "all 0.15s",
|
|
background: deviceMode === "tablet" ? "#e4e4e7" : "transparent",
|
|
color: deviceMode === "tablet" ? "#18181b" : "#71717a",
|
|
}}
|
|
title="Tablet view"
|
|
>
|
|
<Tablet size={12} />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeviceMode("mobile")}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 4,
|
|
border: "none",
|
|
cursor: "pointer",
|
|
transition: "all 0.15s",
|
|
background: deviceMode === "mobile" ? "#e4e4e7" : "transparent",
|
|
color: deviceMode === "mobile" ? "#18181b" : "#71717a",
|
|
}}
|
|
title="Mobile view"
|
|
>
|
|
<Smartphone size={12} />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
width: 1,
|
|
height: 14,
|
|
background: "#e4e4e7",
|
|
margin: "0 4px",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
|
|
{displayUrl ? (
|
|
<a
|
|
href={displayUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title="Open preview in new tab"
|
|
style={{
|
|
color: "#71717a",
|
|
display: "flex",
|
|
padding: "4px",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<ExternalLink size={14} />
|
|
</a>
|
|
) : (
|
|
<div style={{ padding: "4px", opacity: 0.3, flexShrink: 0 }}>
|
|
<ExternalLink size={14} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const bar: React.CSSProperties = {
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
minWidth: 0,
|
|
height: "100%", // Inherit height from parent
|
|
padding: "0 16px",
|
|
gap: 12,
|
|
boxSizing: "border-box",
|
|
background: "#faf8f5",
|
|
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
|
};
|
|
|
|
const primaryGroup: React.CSSProperties = {
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
background: "#f4f4f5",
|
|
padding: 4,
|
|
borderRadius: 8,
|
|
border: "1px solid #e4e4e7",
|
|
};
|