Files
vibn-frontend/components/project/project-header-urls.tsx
Mark Henderson 41fbed31f3 fix(prod): ESM transpile, healthcheck, hosting UX, settings, error msgs
- next.config.ts: add react-markdown + entire unified/remark/rehype
  ecosystem to transpilePackages — fixes TypeError 'z'/'j'/'aa' prod
  crashes caused by ESM-only packages not being bundled for webpack
- Dockerfile: bake HEALTHCHECK --start-period=60s on 127.0.0.1 so
  rolling deploys pass on first health probe (was failing on ::1 IPv6)
- Hosting tab: full rewrite — live URL chip, copy button, redeploy
  button, inline log viewer, domain list, empty state with prompt
  nudge. Single-card layout replaces master-detail for 1-3 endpoints.
- Settings page: new /project/:id/settings route with danger zone +
  typed "delete" confirmation for project deletion
- Status pill: "View logs" link appears on build failures
- URL chips: collapse extras into "+N more" pill when >2 visible
- Chat errors: structured "Tool error:" prefix; network errors
  distinguished from server errors

Made-with: Cursor
2026-04-30 17:12:48 -07:00

132 lines
3.7 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 more" pill (shows the full list in a tooltip via title).
*
* Polls anatomy at the same cadence as the status pill.
*/
import { ExternalLink, Globe, Zap } from "lucide-react";
import { useAnatomy } from "./use-anatomy";
const MAX_VISIBLE = 2;
interface Props {
projectId: string;
}
export function ProjectHeaderUrls({ projectId }: Props) {
const { anatomy } = useAnatomy(projectId, { pollMs: 4000 });
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];
if (allLinks.length === 0) return null;
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 && (
<span
style={overflowPill}
title={hidden.map(l => `${l.label}: ${l.url}`).join("\n")}
>
+{hidden.length} more
</span>
)}
</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 chipLabel: React.CSSProperties = {
overflow: "hidden", textOverflow: "ellipsis",
maxWidth: 180,
};
const overflowPill: React.CSSProperties = {
...chipBase,
borderColor: "#e8e4dc",
color: "#a09a90",
background: "#f8f5f0",
cursor: "default",
};