diff --git a/components/project/project-header-urls.tsx b/components/project/project-header-urls.tsx index e42f88df..fc77b448 100644 --- a/components/project/project-header-urls.tsx +++ b/components/project/project-header-urls.tsx @@ -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(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 (
@@ -71,12 +97,42 @@ export function ProjectHeaderUrls({ projectId }: Props) { ))} {hidden.length > 0 && ( - `${l.label}: ${l.url}`).join("\n")} - > - +{hidden.length} more - +
+ + {overflowOpen && ( +
+
{hidden.length} more endpoint{hidden.length === 1 ? "" : "s"}
+ {hidden.map((l) => ( + setOverflowOpen(false)} + role="menuitem" + > + {l.kind === "live" + ? + : } +
+
{l.label}
+
{l.host}
+
+ +
+ ))} +
+ )} +
)}
); @@ -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", }; diff --git a/components/project/project-stage-pill.tsx b/components/project/project-stage-pill.tsx index 28a5baf7..87ce3036 100644 --- a/components/project/project-stage-pill.tsx +++ b/components/project/project-stage-pill.tsx @@ -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/` 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/`. + 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 ( @@ -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 && (