Ships accumulated WIP that was sitting uncommitted: - New (home) dashboard route pages: overview, code, data/tables, hosting, infrastructure, services, domains, integrations, agents, analytics, api, automations, billing, logs, market, marketing(+seo/social), product, security, storage, users, settings(app/auth). - dashboard-sidebar, project-icon-rail, chat-panel updates; mcp + anatomy route changes; package.json/lock dependency bumps. - Coolify log tooling (scripts/fetch-app-logs.mjs + fetch-app-logs-ssh.mjs) and ai-new-thread.md "Fetching Production Logs" section. Excludes throwaway debug scripts and telemetry audit dumps (the latter contain live credentials and must not be committed).
446 lines
12 KiB
TypeScript
446 lines
12 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,
|
|
}}
|
|
/>
|
|
|
|
<span
|
|
style={{
|
|
opacity: 0.5,
|
|
flexShrink: 0,
|
|
paddingLeft: 4,
|
|
fontFamily: "var(--font-mono), monospace",
|
|
}}
|
|
>
|
|
/
|
|
</span>
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
position: "relative",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<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={{
|
|
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",
|
|
};
|