hero image fills full text column height on desktop
- Grid: items-start → md:items-stretch (both columns same height) - Image: aspect-[4/5] → md:aspect-auto md:h-full (fills column) - Mobile keeps aspect-[3/4] for stacked layout - Bottom of image now lines up with buttons/trust line
This commit is contained in:
@@ -216,6 +216,19 @@ Current oops: ${state.oops.length}
|
||||
loomLink: params.loomLink || "#loomclip",
|
||||
};
|
||||
state.ships.push(entry);
|
||||
|
||||
// Persist to PostgreSQL
|
||||
const addTitle = entry.title.replace(/'/g, "''");
|
||||
const addMetric = (entry.metric || "—").replace(/'/g, "''");
|
||||
const addStatus = entry.status;
|
||||
try {
|
||||
const dbResult = await pi.exec("bash", ["-c",
|
||||
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec \$(docker ps --format {{.Names}} | grep dokploy-postgres) psql -U dokploy -d calvana -t -c \\\"INSERT INTO ships (title, status, metric) VALUES (\\x27${addTitle}\\x27, \\x27${addStatus}\\x27, \\x27${addMetric}\\x27) RETURNING id\\\"'"`
|
||||
], { timeout: 15000 });
|
||||
const dbId = parseInt((dbResult.stdout || "").trim());
|
||||
if (dbId > 0) entry.id = dbId;
|
||||
} catch { /* DB write failed, local state still updated */ }
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Ship #${entry.id} added: "${entry.title}" [${entry.status}]` }],
|
||||
details: { state: { ...state, ships: [...state.ships] } },
|
||||
@@ -242,6 +255,18 @@ Current oops: ${state.oops.length}
|
||||
if (params.deployLink) ship.deployLink = params.deployLink;
|
||||
if (params.loomLink) ship.loomLink = params.loomLink;
|
||||
ship.timestamp = now;
|
||||
|
||||
// Persist update to PostgreSQL
|
||||
const setClauses: string[] = [];
|
||||
if (params.status) setClauses.push(`status='${params.status}'`);
|
||||
if (params.metric) setClauses.push(`metric='${(params.metric || "").replace(/'/g, "''")}'`);
|
||||
setClauses.push("updated_at=now()");
|
||||
try {
|
||||
await pi.exec("bash", ["-c",
|
||||
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec \$(docker ps --format {{.Names}} | grep dokploy-postgres) psql -U dokploy -d calvana -c \\\"UPDATE ships SET ${setClauses.join(", ")} WHERE id=${params.id}\\\"'"`
|
||||
], { timeout: 15000 });
|
||||
} catch { /* DB write failed, local state still updated */ }
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Ship #${ship.id} updated: "${ship.title}" [${ship.status}]` }],
|
||||
details: { state: { ...state, ships: [...state.ships] } },
|
||||
@@ -389,32 +414,82 @@ Current oops: ${state.oops.length}
|
||||
parameters: DeployParams,
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
||||
onUpdate?.({ content: [{ type: "text", text: "Generating HTML..." }] });
|
||||
onUpdate?.({ content: [{ type: "text", text: "Querying database for full ship log..." }] });
|
||||
|
||||
const liveHtml = generateLivePageHtml(state);
|
||||
const sshBase = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
||||
const pgContainer = "$(docker ps --format '{{.Names}}' | grep dokploy-postgres)";
|
||||
const psqlCmd = (sql: string) => `${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec ${pgContainer} psql -U dokploy -d calvana -t -A -F \\\"|||\\\" -c \\\"${sql}\\\"'"`;
|
||||
|
||||
try {
|
||||
// 1. Query all ships from database
|
||||
const shipsResult = await pi.exec("bash", ["-c",
|
||||
psqlCmd("SELECT id, title, status, COALESCE(metric,'—'), COALESCE(details,''), created_at::text, COALESCE(updated_at::text, created_at::text) FROM ships ORDER BY id")
|
||||
], { signal, timeout: 20000 });
|
||||
|
||||
if (shipsResult.code !== 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `DB query failed: ${shipsResult.stderr}` }],
|
||||
details: { state: { ...state }, error: shipsResult.stderr },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Query all oops from database
|
||||
const oopsResult = await pi.exec("bash", ["-c",
|
||||
psqlCmd("SELECT id, description, COALESCE(fix_time,'—'), COALESCE(commit_link,'#commit'), created_at::text FROM oops ORDER BY id")
|
||||
], { signal, timeout: 20000 });
|
||||
|
||||
// 3. Parse DB results into ship/oops arrays
|
||||
const dbShips: Array<{ id: number; title: string; status: string; metric: string; details: string; created: string; updated: string }> = [];
|
||||
for (const line of shipsResult.stdout.trim().split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split("|||");
|
||||
if (parts.length >= 6) {
|
||||
dbShips.push({
|
||||
id: parseInt(parts[0]),
|
||||
title: parts[1],
|
||||
status: parts[2],
|
||||
metric: parts[3],
|
||||
details: parts[4],
|
||||
created: parts[5],
|
||||
updated: parts[6] || parts[5],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dbOops: Array<{ id: number; description: string; fixTime: string; commitLink: string; created: string }> = [];
|
||||
for (const line of (oopsResult.stdout || "").trim().split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split("|||");
|
||||
if (parts.length >= 4) {
|
||||
dbOops.push({
|
||||
id: parseInt(parts[0]),
|
||||
description: parts[1],
|
||||
fixTime: parts[2],
|
||||
commitLink: parts[3],
|
||||
created: parts[4] || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate?.({ content: [{ type: "text", text: `Found ${dbShips.length} ships + ${dbOops.length} oops. Generating HTML...` }] });
|
||||
|
||||
// 4. Generate HTML from DB data
|
||||
const liveHtml = generateLivePageFromDb(dbShips, dbOops);
|
||||
|
||||
if (params.dryRun) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Dry run — generated ${liveHtml.length} bytes of HTML.\n\n${liveHtml.slice(0, 500)}...` }],
|
||||
content: [{ type: "text", text: `Dry run — ${dbShips.length} ships, ${dbOops.length} oops, ${liveHtml.length} bytes HTML.\n\n${liveHtml.slice(0, 500)}...` }],
|
||||
details: { state: { ...state }, dryRun: true },
|
||||
};
|
||||
}
|
||||
|
||||
onUpdate?.({ content: [{ type: "text", text: "Deploying to server..." }] });
|
||||
|
||||
try {
|
||||
// Write HTML to server via SSH + incus exec
|
||||
const escapedHtml = liveHtml.replace(/'/g, "'\\''");
|
||||
const sshCmd = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
||||
const writeCmd = `${sshCmd} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cat > ${DEPLOY_CONFIG.sitePath}/live/index.html << '\\''HTMLEOF'\\''
|
||||
${liveHtml}
|
||||
HTMLEOF
|
||||
'"`;
|
||||
|
||||
// Use base64 to avoid all escaping nightmares
|
||||
// 5. Deploy via base64
|
||||
const b64Html = Buffer.from(liveHtml).toString("base64");
|
||||
const deployResult = await pi.exec("bash", ["-c",
|
||||
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
|
||||
`${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
|
||||
], { signal, timeout: 30000 });
|
||||
|
||||
if (deployResult.code !== 0) {
|
||||
@@ -425,16 +500,16 @@ HTMLEOF
|
||||
};
|
||||
}
|
||||
|
||||
// Rebuild and update docker service
|
||||
// 6. Rebuild and update docker service
|
||||
const rebuildResult = await pi.exec("bash", ["-c",
|
||||
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cd /opt/calvana && docker build -t calvana:latest . 2>&1 | tail -2 && docker service update --force calvana 2>&1 | tail -2'"`
|
||||
`${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cd /opt/calvana && docker build -t calvana:latest . 2>&1 | tail -2 && docker service update --force calvana 2>&1 | tail -2'"`
|
||||
], { signal, timeout: 60000 });
|
||||
|
||||
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
state.lastDeployed = now;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${rebuildResult.stdout}` }],
|
||||
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live — ${dbShips.length} ships + ${dbOops.length} oops from database\n${rebuildResult.stdout}` }],
|
||||
details: { state: { ...state, lastDeployed: now } },
|
||||
};
|
||||
} catch (err: any) {
|
||||
@@ -589,48 +664,77 @@ class ShipLogComponent {
|
||||
// HTML GENERATOR — Builds the /live page from current state
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Keep the old function signature for backward compat but it's no longer called by deploy
|
||||
function generateLivePageHtml(state: ShipLogState): string {
|
||||
const now = new Date().toISOString();
|
||||
return generateLivePageFromDb(
|
||||
state.ships.map(s => ({ id: s.id, title: s.title, status: s.status, metric: s.metric, details: "", created: s.timestamp, updated: s.timestamp })),
|
||||
state.oops.map(o => ({ id: o.id, description: o.description, fixTime: o.fixTime, commitLink: o.commitLink, created: o.timestamp }))
|
||||
);
|
||||
}
|
||||
|
||||
const shipCards = state.ships.map(s => {
|
||||
function generateLivePageFromDb(
|
||||
ships: Array<{ id: number; title: string; status: string; metric: string; details: string; created: string; updated: string }>,
|
||||
oops: Array<{ id: number; description: string; fixTime: string; commitLink: string; created: string }>
|
||||
): string {
|
||||
const now = new Date().toISOString();
|
||||
const shipped = ships.filter(s => s.status === "shipped").length;
|
||||
const shipping = ships.filter(s => s.status === "shipping").length;
|
||||
|
||||
// Group ships by date (newest first)
|
||||
const shipsByDate = new Map<string, typeof ships>();
|
||||
for (const s of [...ships].reverse()) {
|
||||
const date = s.created.split(" ")[0] || s.created.split("T")[0] || "Unknown";
|
||||
if (!shipsByDate.has(date)) shipsByDate.set(date, []);
|
||||
shipsByDate.get(date)!.push(s);
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
|
||||
} catch { return dateStr; }
|
||||
};
|
||||
|
||||
let shipSections = "";
|
||||
for (const [date, dateShips] of shipsByDate) {
|
||||
const cards = dateShips.map(s => {
|
||||
const badgeClass = s.status === "shipped" ? "badge-shipped"
|
||||
: s.status === "shipping" ? "badge-shipping"
|
||||
: "badge-planned";
|
||||
const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1);
|
||||
const titleSuffix = s.status === "shipped" ? " ✓" : "";
|
||||
|
||||
// If details has HTML (from DB), use it; otherwise use metric
|
||||
const hasDetails = s.details && s.details.trim().length > 10 && s.details.includes("<");
|
||||
const detailsBlock = hasDetails
|
||||
? `\n <div class="card-details">${s.details}</div>`
|
||||
: "";
|
||||
|
||||
return ` <div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">${escapeHtml(s.title)}${titleSuffix}</span>
|
||||
<span class="card-id">#${s.id}</span>
|
||||
<span class="card-title">${escapeHtml(s.title)}</span>
|
||||
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
||||
</div>
|
||||
<p class="card-meta">⏱ ${escapeHtml(s.timestamp)}</p>
|
||||
<p class="metric">What moved: ${escapeHtml(s.metric)}</p>
|
||||
<div class="card-links"><a href="${escapeHtml(s.prLink)}">PR</a><a href="${escapeHtml(s.deployLink)}">Deploy</a><a href="${escapeHtml(s.loomLink)}">Loom clip</a></div>
|
||||
<p class="metric">${escapeHtml(s.metric)}</p>${detailsBlock}
|
||||
</div>`;
|
||||
}).join("\n");
|
||||
|
||||
const oopsEntries = state.oops.map(o => {
|
||||
return ` <div class="oops-entry">
|
||||
<span>${escapeHtml(o.description)}${o.fixTime !== "—" ? ` Fixed in ${escapeHtml(o.fixTime)}.` : ""}</span>
|
||||
<a href="${escapeHtml(o.commitLink)}">→ commit</a>
|
||||
</div>`;
|
||||
}).join("\n");
|
||||
|
||||
// If no ships yet, show placeholder
|
||||
const shipsSection = state.ships.length > 0 ? shipCards : ` <div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Warming up...</span>
|
||||
<span class="badge badge-planned">Planned</span>
|
||||
shipSections += `
|
||||
<section class="section day-section">
|
||||
<h2 class="day-header">${formatDate(date)} <span class="day-count">${dateShips.length} ship${dateShips.length !== 1 ? "s" : ""}</span></h2>
|
||||
<div class="card-grid">
|
||||
${cards}
|
||||
</div>
|
||||
<p class="card-meta">⏱ —</p>
|
||||
<p class="metric">What moved: —</p>
|
||||
</div>`;
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const oopsSection = state.oops.length > 0 ? oopsEntries : ` <div class="oops-entry">
|
||||
<span>Nothing broken yet. Give it time.</span>
|
||||
<a href="#commit">→ waiting</a>
|
||||
</div>`;
|
||||
const oopsEntries = oops.length > 0
|
||||
? oops.map(o => ` <div class="oops-entry">
|
||||
<span class="oops-id">#${o.id}</span>
|
||||
<span>${escapeHtml(o.description)}${o.fixTime !== "—" ? ` <em>Fixed in ${escapeHtml(o.fixTime)}.</em>` : ""}</span>
|
||||
${o.commitLink && o.commitLink !== "#commit" ? `<a href="${escapeHtml(o.commitLink)}">→ commit</a>` : ""}
|
||||
</div>`).join("\n")
|
||||
: ` <div class="oops-entry"><span>Nothing broken yet. Give it time.</span></div>`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -646,6 +750,26 @@ function generateLivePageHtml(state: ShipLogState): string {
|
||||
<meta name="twitter:card" content="summary">
|
||||
<link rel="canonical" href="https://${DEPLOY_CONFIG.domain}/live">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.stats-bar { display:flex; gap:2rem; margin:1.5rem 0 2rem; flex-wrap:wrap; }
|
||||
.stat { text-align:center; }
|
||||
.stat-num { font-size:2rem; font-weight:800; line-height:1; }
|
||||
.stat-label { font-size:.75rem; text-transform:uppercase; letter-spacing:.08em; opacity:.5; margin-top:.25rem; }
|
||||
.stat-num.green { color:#22c55e; }
|
||||
.stat-num.amber { color:#f59e0b; }
|
||||
.stat-num.red { color:#ef4444; }
|
||||
.day-header { font-size:1.1rem; font-weight:700; margin-bottom:.75rem; display:flex; align-items:center; gap:.75rem; }
|
||||
.day-count { font-size:.75rem; font-weight:500; opacity:.4; }
|
||||
.day-section { margin-bottom:1.5rem; }
|
||||
.card-id { font-size:.7rem; font-weight:700; opacity:.3; margin-right:.5rem; }
|
||||
.card-details { margin-top:.5rem; font-size:.8rem; opacity:.65; line-height:1.5; }
|
||||
.card-details ul { margin:.25rem 0; padding-left:1.25rem; }
|
||||
.card-details li { margin-bottom:.15rem; }
|
||||
.oops-id { font-size:.7rem; font-weight:700; opacity:.3; margin-right:.5rem; }
|
||||
.card { position:relative; }
|
||||
.card-header { display:flex; align-items:flex-start; gap:.5rem; flex-wrap:wrap; }
|
||||
.card-title { flex:1; min-width:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
@@ -660,14 +784,15 @@ function generateLivePageHtml(state: ShipLogState): string {
|
||||
</nav>
|
||||
<main class="page">
|
||||
<h1 class="hero-title">Live Shipping Log</h1>
|
||||
<p class="subtitle">Intentional chaos. Full receipts.</p>
|
||||
<p class="subtitle">Intentional chaos. Full receipts. Every ship ever.</p>
|
||||
|
||||
<section class="section">
|
||||
<h2>Today's Ships</h2>
|
||||
<div class="card-grid">
|
||||
${shipsSection}
|
||||
<div class="stats-bar">
|
||||
<div class="stat"><div class="stat-num green">${shipped}</div><div class="stat-label">Shipped</div></div>
|
||||
<div class="stat"><div class="stat-num amber">${shipping}</div><div class="stat-label">In Flight</div></div>
|
||||
<div class="stat"><div class="stat-num">${ships.length}</div><div class="stat-label">Total</div></div>
|
||||
<div class="stat"><div class="stat-num red">${oops.length}</div><div class="stat-label">Oops</div></div>
|
||||
</div>
|
||||
</section>
|
||||
${shipSections}
|
||||
|
||||
<section class="section">
|
||||
<div class="two-col">
|
||||
@@ -696,13 +821,13 @@ ${shipsSection}
|
||||
<h2>Oops Log</h2>
|
||||
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
|
||||
<div class="oops-log">
|
||||
${oopsSection}
|
||||
${oopsEntries}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p class="footer-tagline">${SITE_CONFIG.tagline}</p>
|
||||
<p style="margin-top:.4rem">Last updated: ${now}</p>
|
||||
<p style="margin-top:.4rem">Last updated: ${now} · ${ships.length} ships from PostgreSQL</p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function HomePage() {
|
||||
{/* ━━ HERO (dark) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
<section className="bg-gray-950 overflow-hidden">
|
||||
<div className="max-w-5xl mx-auto px-6 pt-20 pb-16 md:pt-28 md:pb-20">
|
||||
<div className="grid md:grid-cols-12 gap-10 md:gap-14 items-start">
|
||||
<div className="grid md:grid-cols-12 gap-10 md:gap-14 items-start md:items-stretch">
|
||||
|
||||
{/* ── Text column ── */}
|
||||
<div className="md:col-span-7 pt-2 md:pt-6 stagger-children">
|
||||
@@ -99,7 +99,7 @@ export default function HomePage() {
|
||||
|
||||
{/* ── Image column ── */}
|
||||
<div className="md:col-span-5" style={{ opacity: 0, animation: "fadeUp 0.5s ease-out 0.25s forwards" }}>
|
||||
<div className="aspect-[3/4] md:aspect-[4/5] w-full relative overflow-hidden">
|
||||
<div className="aspect-[3/4] md:aspect-auto md:h-full w-full relative overflow-hidden">
|
||||
<Image
|
||||
src="/images/landing/00-hero.jpg"
|
||||
alt="Payment received notification on phone at a charity gala dinner"
|
||||
|
||||
Reference in New Issue
Block a user