- 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
132 lines
3.7 KiB
TypeScript
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",
|
|
};
|