301 lines
9.4 KiB
TypeScript
301 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Project header URL chips — surfaces the user's "front door" URLs
|
|
* next to the status pill so they're one click away from any tab.
|
|
*
|
|
* - Live chips → every Coolify endpoint with an attached fqdn
|
|
* - Prev. chips → every running dev-server preview
|
|
*
|
|
* When there are more than MAX_VISIBLE total links, extras collapse
|
|
* into a "+N" pill that opens a popover with the full list as
|
|
* clickable links (was a `title=` tooltip before — popover is
|
|
* discoverable on touch and keyboard, tooltip wasn't).
|
|
*
|
|
* Polls anatomy every 30s for URL chips (stage pill polls faster while deploying).
|
|
*/
|
|
|
|
import { ExternalLink, Globe, Play, Zap } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { dashboardBridgeScriptUrl } from "@/lib/dashboard-bridge-url";
|
|
import { useAnatomy } from "./use-anatomy";
|
|
|
|
const MAX_VISIBLE = 3;
|
|
|
|
const START_PREVIEW_PROMPT_BASE =
|
|
"Start the dev server for the user-facing app in this project on port 3000 and share the preview URL so I can see what it looks like. If multiple services exist (frontend + API + worker), pick the user-facing one. If a server is already running, just share the URL.";
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
}
|
|
|
|
export function ProjectHeaderUrls({ projectId }: Props) {
|
|
/** Rare churn — stage pill polls when deploys are active; 30s is plenty for new preview URLs */
|
|
const { anatomy } = useAnatomy(projectId, { pollMs: 30000 });
|
|
const [overflowOpen, setOverflowOpen] = useState(false);
|
|
const overflowRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close popover on outside click / Escape — both expected by users
|
|
// who don't realize it's modal-ish.
|
|
useEffect(() => {
|
|
if (!overflowOpen) return;
|
|
function onDoc(e: MouseEvent) {
|
|
if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
|
|
setOverflowOpen(false);
|
|
}
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") setOverflowOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", onDoc);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDoc);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [overflowOpen]);
|
|
|
|
if (!anatomy) return null;
|
|
|
|
const liveLinks = anatomy.hosting.live
|
|
.filter((l) => !!l.fqdn)
|
|
.map((l) => ({
|
|
key: l.uuid,
|
|
kind: "live" as const,
|
|
label: l.name,
|
|
url: ensureScheme(l.fqdn!),
|
|
host: stripScheme(l.fqdn!),
|
|
}));
|
|
|
|
const previewLinks = anatomy.hosting.previews
|
|
.filter((p) => p.state === "running" && p.url)
|
|
.map((p) => ({
|
|
key: p.id,
|
|
kind: "preview" as const,
|
|
label: `${p.name}:${p.port}`,
|
|
url: p.url,
|
|
host: hostOf(p.url),
|
|
}));
|
|
|
|
const allLinks = [...liveLinks, ...previewLinks];
|
|
|
|
// Empty header is dishonest UX — the user has no idea whether they
|
|
// SHOULD have a preview or whether nothing has been built yet. Surface
|
|
// a one-click "Start preview" affordance instead. Clicking it opens
|
|
// the chat panel (if collapsed) and fires the canned prompt; the
|
|
// moment dev_server_start lands in anatomy (poll cadence: 4s) this
|
|
// button is replaced by the real preview chip.
|
|
if (allLinks.length === 0) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const bridgeUrl = dashboardBridgeScriptUrl();
|
|
const prompt =
|
|
START_PREVIEW_PROMPT_BASE +
|
|
` After startup, add the preview picker script once (e.g. next/script in the root layout): ${bridgeUrl}`;
|
|
window.dispatchEvent(
|
|
new CustomEvent("vibn:chat-prompt", {
|
|
detail: { prompt, scopeProjectId: projectId },
|
|
}),
|
|
);
|
|
}}
|
|
title="Start the dev server and share a preview URL in chat"
|
|
style={startPreviewBtn}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = "#f6f2ec";
|
|
e.currentTarget.style.borderColor = "#d9d2c5";
|
|
e.currentTarget.style.color = "#1a1a1a";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = "#fff";
|
|
e.currentTarget.style.borderColor = "#e8e4dc";
|
|
e.currentTarget.style.color = "#6b665e";
|
|
}}
|
|
>
|
|
<Play size={10} style={{ flexShrink: 0 }} fill="currentColor" />
|
|
<span>Preview</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const visible = allLinks.slice(0, MAX_VISIBLE);
|
|
const hidden = allLinks.slice(MAX_VISIBLE);
|
|
|
|
return (
|
|
<div style={wrap}>
|
|
{visible.map((l) => (
|
|
<a
|
|
key={l.key}
|
|
href={l.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
style={l.kind === "live" ? liveChip : previewChip}
|
|
title={`${l.label} → ${l.host}`}
|
|
>
|
|
{l.kind === "live"
|
|
? <Globe size={11} style={{ flexShrink: 0 }} />
|
|
: <Zap size={11} style={{ flexShrink: 0 }} />}
|
|
<span style={chipLabel}>{l.label}</span>
|
|
<ExternalLink size={10} style={{ flexShrink: 0, opacity: 0.7 }} />
|
|
</a>
|
|
))}
|
|
{hidden.length > 0 && (
|
|
<div ref={overflowRef} style={{ position: "relative" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOverflowOpen((v) => !v)}
|
|
aria-expanded={overflowOpen}
|
|
aria-haspopup="true"
|
|
style={overflowPill}
|
|
>
|
|
+{hidden.length}
|
|
</button>
|
|
{overflowOpen && (
|
|
<div role="menu" style={popoverStyle}>
|
|
<div style={popoverHeader}>{hidden.length} more endpoint{hidden.length === 1 ? "" : "s"}</div>
|
|
{hidden.map((l) => (
|
|
<a
|
|
key={l.key}
|
|
href={l.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
style={popoverItem}
|
|
onClick={() => setOverflowOpen(false)}
|
|
role="menuitem"
|
|
>
|
|
{l.kind === "live"
|
|
? <Globe size={12} style={{ flexShrink: 0, color: "#1a1a1a" }} />
|
|
: <Zap size={12} style={{ flexShrink: 0, color: "#3d5afe" }} />}
|
|
<div style={popoverItemText}>
|
|
<div style={popoverItemLabel}>{l.label}</div>
|
|
<div style={popoverItemHost}>{l.host}</div>
|
|
</div>
|
|
<ExternalLink size={11} style={{ flexShrink: 0, opacity: 0.5 }} />
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function ensureScheme(host: string): string {
|
|
if (/^https?:\/\//i.test(host)) return host;
|
|
return `https://${host}`;
|
|
}
|
|
function stripScheme(host: string): string {
|
|
return host.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
|
}
|
|
function hostOf(url: string): string {
|
|
try { return new URL(url).host; } catch { return url; }
|
|
}
|
|
|
|
const wrap: React.CSSProperties = {
|
|
display: "flex", gap: 6, alignItems: "center",
|
|
flexWrap: "wrap",
|
|
};
|
|
|
|
const chipBase: React.CSSProperties = {
|
|
display: "inline-flex", alignItems: "center", gap: 6,
|
|
padding: "4px 10px", borderRadius: 4,
|
|
fontSize: "0.72rem", fontWeight: 500,
|
|
textDecoration: "none",
|
|
whiteSpace: "nowrap", maxWidth: 220,
|
|
border: "1px solid",
|
|
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
|
transition: "background 0.15s, border-color 0.15s",
|
|
};
|
|
const liveChip: React.CSSProperties = {
|
|
...chipBase,
|
|
color: "#1a1a1a", borderColor: "#e8e4dc", background: "#fff",
|
|
};
|
|
const previewChip: React.CSSProperties = {
|
|
...chipBase,
|
|
color: "#3d5afe", borderColor: "#3d5afe33", background: "#3d5afe08",
|
|
};
|
|
const startPreviewBtn: React.CSSProperties = {
|
|
...chipBase,
|
|
color: "#6b665e",
|
|
borderColor: "#e8e4dc",
|
|
background: "#fff",
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 500,
|
|
transition: "background 0.15s, border-color 0.15s, color 0.15s",
|
|
};
|
|
const chipLabel: React.CSSProperties = {
|
|
overflow: "hidden", textOverflow: "ellipsis",
|
|
maxWidth: 180,
|
|
};
|
|
const overflowPill: React.CSSProperties = {
|
|
...chipBase,
|
|
borderColor: "#e8e4dc",
|
|
color: "#6b665e",
|
|
background: "#f8f5f0",
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 500,
|
|
};
|
|
|
|
const popoverStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: "calc(100% + 6px)",
|
|
right: 0,
|
|
minWidth: 240,
|
|
maxWidth: 360,
|
|
padding: 4,
|
|
background: "#fff",
|
|
border: "1px solid #e8e4dc",
|
|
borderRadius: 6,
|
|
boxShadow: "0 6px 20px rgba(0,0,0,0.08)",
|
|
zIndex: 50,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
|
};
|
|
const popoverHeader: React.CSSProperties = {
|
|
padding: "6px 10px 4px",
|
|
fontSize: "0.65rem",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.04em",
|
|
color: "#a09a90",
|
|
fontWeight: 600,
|
|
};
|
|
const popoverItem: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "8px 10px",
|
|
borderRadius: 4,
|
|
textDecoration: "none",
|
|
color: "#1a1a1a",
|
|
fontSize: "0.78rem",
|
|
cursor: "pointer",
|
|
};
|
|
const popoverItemText: React.CSSProperties = {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
overflow: "hidden",
|
|
};
|
|
const popoverItemLabel: React.CSSProperties = {
|
|
fontWeight: 500,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
};
|
|
const popoverItemHost: React.CSSProperties = {
|
|
fontSize: "0.7rem",
|
|
color: "#a09a90",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
};
|