feat(ux): URL chip overflow popover + status pill deep-link
3.4: Bumped MAX_VISIBLE chips to 3 (was 2). When >3 endpoints exist, extras collapse into a "+N" pill that opens a click-to-open popover listing the rest as real clickable links. Replaces the previous title-attribute tooltip which was undiscoverable on touch and keyboard. Closes on outside click and Escape. 3.5: Status pill "Logs" affordance now deep-links to the Coolify project page (one click from build logs) instead of Coolify's root. Also surfaced on `deploying` and `down` states, not just `build_failed` — when something's happening Coolify-side, users want to peek regardless of the exact state. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,15 +8,18 @@
|
||||
* - Prev. chips → every running dev-server preview
|
||||
*
|
||||
* When there are more than MAX_VISIBLE total links, extras collapse
|
||||
* into a "+N more" pill (shows the full list in a tooltip via title).
|
||||
* 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 at the same cadence as the status pill.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Globe, Zap } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAnatomy } from "./use-anatomy";
|
||||
|
||||
const MAX_VISIBLE = 2;
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -24,6 +27,29 @@ interface Props {
|
||||
|
||||
export function ProjectHeaderUrls({ projectId }: Props) {
|
||||
const { anatomy } = useAnatomy(projectId, { pollMs: 4000 });
|
||||
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
|
||||
@@ -50,7 +76,7 @@ export function ProjectHeaderUrls({ projectId }: Props) {
|
||||
if (allLinks.length === 0) return null;
|
||||
|
||||
const visible = allLinks.slice(0, MAX_VISIBLE);
|
||||
const hidden = allLinks.slice(MAX_VISIBLE);
|
||||
const hidden = allLinks.slice(MAX_VISIBLE);
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
@@ -71,12 +97,42 @@ export function ProjectHeaderUrls({ projectId }: Props) {
|
||||
</a>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
<span
|
||||
style={overflowPill}
|
||||
title={hidden.map(l => `${l.label}: ${l.url}`).join("\n")}
|
||||
>
|
||||
+{hidden.length} more
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
@@ -125,7 +181,65 @@ const chipLabel: React.CSSProperties = {
|
||||
const overflowPill: React.CSSProperties = {
|
||||
...chipBase,
|
||||
borderColor: "#e8e4dc",
|
||||
color: "#a09a90",
|
||||
color: "#6b665e",
|
||||
background: "#f8f5f0",
|
||||
cursor: "default",
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -59,11 +59,35 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
|
||||
}
|
||||
|
||||
const visual = VISUALS[state.kind];
|
||||
// For build failures, surface a "View logs" link next to the pill so
|
||||
// the user can immediately see why the deploy broke.
|
||||
const coolifyDeployUrl = anatomy?.hosting.live[0]?.uuid
|
||||
? `${typeof window !== "undefined" ? "" : ""}` // resolved client-side
|
||||
: null;
|
||||
|
||||
// Deep-link target for the "Logs" affordance. Coolify v4 redirects
|
||||
// `/project/<uuid>` to that project's first environment, putting
|
||||
// the user one click from any application's deployment logs. We
|
||||
// don't store the environment UUID in anatomy (yet), so this is
|
||||
// the closest we can get without an extra Coolify call. When
|
||||
// anatomy exposes `lastBuildDeploymentUuid` we can deep-link
|
||||
// straight to `/applications/.../deployment/<dep-uuid>`.
|
||||
const coolifyBase = process.env.NEXT_PUBLIC_COOLIFY_URL ?? "";
|
||||
const coolifyProjectUuid = anatomy?.project?.coolifyProjectUuid;
|
||||
const coolifyDeepLink =
|
||||
coolifyBase && coolifyProjectUuid
|
||||
? `${coolifyBase.replace(/\/$/, "")}/project/${coolifyProjectUuid}`
|
||||
: coolifyBase || null;
|
||||
|
||||
// Show the "Logs" affordance whenever there's something interesting
|
||||
// happening Coolify-side: a build is in flight, the last build
|
||||
// failed, or apps are down. Hide on `live` and `empty` to avoid
|
||||
// visual noise when nothing's wrong.
|
||||
const showLogsLink =
|
||||
coolifyDeepLink &&
|
||||
(state.kind === "build_failed" ||
|
||||
state.kind === "deploying" ||
|
||||
state.kind === "down");
|
||||
|
||||
const logsLinkColor =
|
||||
state.kind === "build_failed" || state.kind === "down"
|
||||
? "#c5392b"
|
||||
: "#3d5afe";
|
||||
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
@@ -74,15 +98,15 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
|
||||
title={state.reason}
|
||||
spinning={state.kind === "deploying"}
|
||||
/>
|
||||
{state.kind === "build_failed" && anatomy?.hosting.live[0] && (
|
||||
{showLogsLink && (
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_COOLIFY_URL ?? ""}`}
|
||||
href={coolifyDeepLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Open Coolify to view build logs"
|
||||
title={`Open Coolify build logs in a new tab`}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 3,
|
||||
fontSize: "0.68rem", color: "#c5392b",
|
||||
fontSize: "0.68rem", color: logsLinkColor,
|
||||
textDecoration: "none", opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user