This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/components/project/project-header-urls.tsx

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",
};