fix stacking cards: flatMap siblings, no wrappers, no shadow, z < header
ROOT CAUSE: each card was wrapped in its own div (min-h-[85vh]), scoping sticky to that wrapper — cards could NEVER overlap. FIX: flatMap returns all sticky divs + h-4 spacers as direct siblings under the same parent (mt-14). Sticky now works correctly — each card overlaps the previous with 20px peek. - Removed all shadows (border-gray-100 only) - z-index: 1-4 (was 10-40, conflicting with nav z-40) - top: 72/92/112/132px (20px stagger) - h-4 spacers between cards (no big white gaps) - Regenerated dinner image: dark navy table, candlelight, £5,000 pledge card — zero white space (was white tablecloth)
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Calvana Ship Log Extension
|
* Calvana Ship Log Extension — DB-ONLY Architecture
|
||||||
*
|
*
|
||||||
* Automatically tracks what you're shipping and updates the live Calvana site.
|
* ZERO in-memory state. ZERO session reconstruction.
|
||||||
|
* Every read hits PostgreSQL. Every write hits PostgreSQL.
|
||||||
|
* If DB is unreachable, operations fail loudly — never silently use stale data.
|
||||||
*
|
*
|
||||||
* Tools (LLM-callable):
|
* Tools (LLM-callable):
|
||||||
* - calvana_ship: Add/update/complete shipping log entries
|
* - calvana_ship: Add/update/complete shipping log entries
|
||||||
@@ -11,14 +13,6 @@
|
|||||||
* Commands (user):
|
* Commands (user):
|
||||||
* /ships — View current shipping log
|
* /ships — View current shipping log
|
||||||
* /ship-deploy — Force deploy to calvana.quikcue.com
|
* /ship-deploy — Force deploy to calvana.quikcue.com
|
||||||
*
|
|
||||||
* How it works:
|
|
||||||
* 1. When you work on tasks, the LLM uses calvana_ship to track progress
|
|
||||||
* 2. If something breaks, calvana_oops logs it
|
|
||||||
* 3. calvana_deploy rebuilds the /live page HTML and pushes it to the server
|
|
||||||
* 4. The extension auto-injects context so the LLM knows to track ships
|
|
||||||
*
|
|
||||||
* Edit the SSH/deploy config in the DEPLOY_CONFIG section below.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
import { StringEnum } from "@mariozechner/pi-ai";
|
||||||
@@ -27,7 +21,7 @@ import { Text, truncateToWidth, matchesKey } from "@mariozechner/pi-tui";
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// CONFIGURATION — Edit these to change deploy target, copy, links
|
// CONFIGURATION
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const DEPLOY_CONFIG = {
|
const DEPLOY_CONFIG = {
|
||||||
@@ -46,36 +40,91 @@ const SITE_CONFIG = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// TYPES
|
// DB ACCESS — Single source of truth. No caching. No fallbacks.
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
type ShipStatus = "planned" | "shipping" | "shipped";
|
const SSH_BASE = `ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
||||||
|
const PG_CONTAINER_CMD = `$(docker ps --format '{{.Names}}' | grep dokploy-postgres)`;
|
||||||
|
|
||||||
interface ShipEntry {
|
/**
|
||||||
id: number;
|
* Run a SQL query against the calvana DB.
|
||||||
title: string;
|
* Uses base64 encoding to bypass the 7-layer quoting hell
|
||||||
status: ShipStatus;
|
* (local bash → SSH → remote bash → incus → container bash → docker → psql).
|
||||||
timestamp: string;
|
* Returns raw stdout. Throws on failure — callers MUST handle errors.
|
||||||
metric: string;
|
* No silent fallbacks. No swallowed exceptions.
|
||||||
prLink: string;
|
*/
|
||||||
deployLink: string;
|
async function dbQuery(pi: ExtensionAPI, sql: string, timeout = 15000): Promise<string> {
|
||||||
loomLink: string;
|
// Base64-encode the SQL to avoid ALL quoting issues through the SSH/incus/docker chain
|
||||||
|
const b64Sql = Buffer.from(sql).toString("base64");
|
||||||
|
const result = await pi.exec("bash", ["-c",
|
||||||
|
`${SSH_BASE} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Sql} | base64 -d | docker exec -i ${PG_CONTAINER_CMD} psql -U dokploy -d calvana -t -A -F \\\"|||\\\"'"`
|
||||||
|
], { timeout });
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`DB query failed (exit ${result.code}): ${result.stderr?.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return result.stdout?.trim() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OopsEntry {
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
// DB READ HELPERS — Always fresh from DB. Never cached.
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface DbShip {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
metric: string;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbOops {
|
||||||
id: number;
|
id: number;
|
||||||
description: string;
|
description: string;
|
||||||
fixTime: string;
|
fixTime: string;
|
||||||
commitLink: string;
|
commitLink: string;
|
||||||
timestamp: string;
|
created: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShipLogState {
|
async function fetchShips(pi: ExtensionAPI): Promise<DbShip[]> {
|
||||||
ships: ShipEntry[];
|
const raw = await dbQuery(pi, "SELECT id, title, status, COALESCE(metric, '-'), created_at::text FROM ships ORDER BY id");
|
||||||
oops: OopsEntry[];
|
if (!raw) return [];
|
||||||
nextShipId: number;
|
const ships: DbShip[] = [];
|
||||||
nextOopsId: number;
|
for (const line of raw.split("\n")) {
|
||||||
lastDeployed: string | null;
|
if (!line.trim()) continue;
|
||||||
|
const p = line.split("|||");
|
||||||
|
if (p.length >= 5) {
|
||||||
|
ships.push({ id: parseInt(p[0]), title: p[1], status: p[2], metric: p[3], created: p[4] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ships;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOops(pi: ExtensionAPI): Promise<DbOops[]> {
|
||||||
|
const raw = await dbQuery(pi, "SELECT id, description, COALESCE(fix_time, '-'), COALESCE(commit_link, '#commit'), created_at::text FROM oops ORDER BY id");
|
||||||
|
if (!raw) return [];
|
||||||
|
const oops: DbOops[] = [];
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const p = line.split("|||");
|
||||||
|
if (p.length >= 4) {
|
||||||
|
oops.push({ id: parseInt(p[0]), description: p[1], fixTime: p[2], commitLink: p[3], created: p[4] || "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get summary counts for status bar / system prompt. */
|
||||||
|
async function fetchSummary(pi: ExtensionAPI): Promise<{ total: number; shipped: number; oops: number }> {
|
||||||
|
try {
|
||||||
|
const raw = await dbQuery(pi,
|
||||||
|
"SELECT (SELECT count(*) FROM ships), (SELECT count(*) FROM ships WHERE status='shipped'), (SELECT count(*) FROM oops)"
|
||||||
|
);
|
||||||
|
const p = raw.split("|||");
|
||||||
|
return { total: parseInt(p[0]) || 0, shipped: parseInt(p[1]) || 0, oops: parseInt(p[2]) || 0 };
|
||||||
|
} catch {
|
||||||
|
return { total: -1, shipped: -1, oops: -1 }; // -1 signals DB unreachable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
@@ -109,116 +158,34 @@ const DeployParams = Type.Object({
|
|||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
// ── State ──
|
// ── NO in-memory state. Only a cache for the status bar display. ──
|
||||||
let state: ShipLogState = {
|
let lastDeployed: string | null = null;
|
||||||
ships: [],
|
let statusCache = { total: 0, shipped: 0, oops: 0 };
|
||||||
oops: [],
|
|
||||||
nextShipId: 1,
|
|
||||||
nextOopsId: 1,
|
|
||||||
lastDeployed: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── State reconstruction: DB first, session fallback ──
|
const refreshStatusBar = async (ctx: ExtensionContext) => {
|
||||||
const reconstructFromDb = async () => {
|
if (!ctx.hasUI) return;
|
||||||
try {
|
const summary = await fetchSummary(pi);
|
||||||
const sshBase = `ssh -o ConnectTimeout=5 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
if (summary.total === -1) {
|
||||||
const pgContainer = "$(docker ps --format '{{.Names}}' | grep dokploy-postgres)";
|
ctx.ui.setStatus("calvana", ctx.ui.theme.fg("error", "🚀 DB unreachable"));
|
||||||
|
} else {
|
||||||
const shipsResult = await pi.exec("bash", ["-c",
|
statusCache = summary;
|
||||||
`${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec ${pgContainer} psql -U dokploy -d calvana -t -A -F \\\"|||\\\" -c \\\"SELECT id, title, status, COALESCE(metric, chr(45)), created_at::text FROM ships ORDER BY id\\\"'" 2>/dev/null`
|
ctx.ui.setStatus("calvana", ctx.ui.theme.fg("dim",
|
||||||
], { timeout: 15000 });
|
`🚀 ${summary.shipped}/${summary.total} shipped · ${summary.oops} oops (DB)`
|
||||||
|
));
|
||||||
if (shipsResult.code === 0 && shipsResult.stdout.trim()) {
|
|
||||||
state.ships = [];
|
|
||||||
let maxId = 0;
|
|
||||||
for (const line of shipsResult.stdout.trim().split("\n")) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
const parts = line.split("|||");
|
|
||||||
if (parts.length >= 5) {
|
|
||||||
const id = parseInt(parts[0]);
|
|
||||||
if (id > maxId) maxId = id;
|
|
||||||
state.ships.push({
|
|
||||||
id,
|
|
||||||
title: parts[1],
|
|
||||||
status: parts[2] as ShipStatus,
|
|
||||||
timestamp: parts[4],
|
|
||||||
metric: parts[3],
|
|
||||||
prLink: "#pr",
|
|
||||||
deployLink: "#deploy",
|
|
||||||
loomLink: "#loomclip",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.nextShipId = maxId + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oopsResult = await pi.exec("bash", ["-c",
|
|
||||||
`${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec ${pgContainer} psql -U dokploy -d calvana -t -A -F \\\"|||\\\" -c \\\"SELECT id, description, COALESCE(fix_time, chr(45)), COALESCE(commit_link, chr(35)), created_at::text FROM oops ORDER BY id\\\"'" 2>/dev/null`
|
|
||||||
], { timeout: 15000 });
|
|
||||||
|
|
||||||
if (oopsResult.code === 0 && oopsResult.stdout.trim()) {
|
|
||||||
state.oops = [];
|
|
||||||
let maxOopsId = 0;
|
|
||||||
for (const line of oopsResult.stdout.trim().split("\n")) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
const parts = line.split("|||");
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
const id = parseInt(parts[0]);
|
|
||||||
if (id > maxOopsId) maxOopsId = id;
|
|
||||||
state.oops.push({
|
|
||||||
id,
|
|
||||||
description: parts[1],
|
|
||||||
fixTime: parts[2],
|
|
||||||
commitLink: parts[3],
|
|
||||||
timestamp: parts[4] || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.nextOopsId = maxOopsId + 1;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// DB unavailable — fall through to session reconstruction
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reconstructState = async (ctx: ExtensionContext) => {
|
// ── Session events: just refresh the status bar. NO state reconstruction. ──
|
||||||
state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null };
|
pi.on("session_start", async (_event, ctx) => await refreshStatusBar(ctx));
|
||||||
|
pi.on("session_switch", async (_event, ctx) => await refreshStatusBar(ctx));
|
||||||
// Always try DB first — this is the source of truth
|
pi.on("session_fork", async (_event, ctx) => await refreshStatusBar(ctx));
|
||||||
await reconstructFromDb();
|
pi.on("session_tree", async (_event, ctx) => await refreshStatusBar(ctx));
|
||||||
|
pi.on("turn_end", async (_event, ctx) => await refreshStatusBar(ctx));
|
||||||
// If DB returned data, we're done
|
|
||||||
if (state.ships.length > 0) return;
|
|
||||||
|
|
||||||
// Fallback: reconstruct from session history (when DB is unreachable)
|
|
||||||
for (const entry of ctx.sessionManager.getBranch()) {
|
|
||||||
if (entry.type !== "message") continue;
|
|
||||||
const msg = entry.message;
|
|
||||||
if (msg.role !== "toolResult") continue;
|
|
||||||
if (msg.toolName === "calvana_ship" || msg.toolName === "calvana_oops" || msg.toolName === "calvana_deploy") {
|
|
||||||
const details = msg.details as { state?: ShipLogState } | undefined;
|
|
||||||
if (details?.state) {
|
|
||||||
state = details.state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
await reconstructState(ctx);
|
|
||||||
if (ctx.hasUI) {
|
|
||||||
const theme = ctx.ui.theme;
|
|
||||||
const shipCount = state.ships.length;
|
|
||||||
const shipped = state.ships.filter(s => s.status === "shipped").length;
|
|
||||||
ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped (DB)`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pi.on("session_switch", async (_event, ctx) => await reconstructState(ctx));
|
|
||||||
pi.on("session_fork", async (_event, ctx) => await reconstructState(ctx));
|
|
||||||
pi.on("session_tree", async (_event, ctx) => await reconstructState(ctx));
|
|
||||||
|
|
||||||
// ── Inject context so LLM knows about ship tracking ──
|
// ── Inject context so LLM knows about ship tracking ──
|
||||||
pi.on("before_agent_start", async (event, _ctx) => {
|
pi.on("before_agent_start", async (event, _ctx) => {
|
||||||
|
const s = await fetchSummary(pi);
|
||||||
|
const dbStatus = s.total === -1 ? "⚠️ DB UNREACHABLE" : `${s.total} ships (${s.shipped} shipped), ${s.oops} oops`;
|
||||||
const shipContext = `
|
const shipContext = `
|
||||||
[Calvana Ship Log Extension Active — DB-backed]
|
[Calvana Ship Log Extension Active — DB-backed]
|
||||||
Ship log is persisted in PostgreSQL (calvana DB on dokploy-postgres).
|
Ship log is persisted in PostgreSQL (calvana DB on dokploy-postgres).
|
||||||
@@ -236,29 +203,13 @@ Rules:
|
|||||||
- After significant changes, use calvana_deploy to push updates live.
|
- After significant changes, use calvana_deploy to push updates live.
|
||||||
- calvana_deploy reads from the DATABASE — it shows ALL ships ever, not just this session.
|
- calvana_deploy reads from the DATABASE — it shows ALL ships ever, not just this session.
|
||||||
|
|
||||||
Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.status === "shipped").length} shipped), ${state.oops.length} oops
|
Current state from DB: ${dbStatus}
|
||||||
`;
|
`;
|
||||||
return {
|
return { systemPrompt: event.systemPrompt + shipContext };
|
||||||
systemPrompt: event.systemPrompt + shipContext,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Update status bar on turn end ──
|
|
||||||
pi.on("turn_end", async (_event, ctx) => {
|
|
||||||
if (ctx.hasUI) {
|
|
||||||
const theme = ctx.ui.theme;
|
|
||||||
const shipped = state.ships.filter(s => s.status === "shipped").length;
|
|
||||||
const shipping = state.ships.filter(s => s.status === "shipping").length;
|
|
||||||
const total = state.ships.length;
|
|
||||||
let statusText = `🚀 ${shipped}/${total} shipped`;
|
|
||||||
if (shipping > 0) statusText += ` · ${shipping} in flight`;
|
|
||||||
if (state.lastDeployed) statusText += ` · last deploy ${state.lastDeployed}`;
|
|
||||||
ctx.ui.setStatus("calvana", theme.fg("dim", statusText));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
// TOOL: calvana_ship
|
// TOOL: calvana_ship — ALL operations go directly to DB
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
@@ -268,105 +219,67 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
parameters: ShipParams,
|
parameters: ShipParams,
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||||
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
|
|
||||||
|
|
||||||
switch (params.action) {
|
switch (params.action) {
|
||||||
case "add": {
|
case "add": {
|
||||||
if (!params.title) {
|
if (!params.title) {
|
||||||
return {
|
return { content: [{ type: "text", text: "Error: title required" }], isError: true };
|
||||||
content: [{ type: "text", text: "Error: title required" }],
|
|
||||||
details: { state: { ...state }, error: "title required" },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const entry: ShipEntry = {
|
const title = params.title.replace(/'/g, "''");
|
||||||
id: state.nextShipId++,
|
const status = params.status || "planned";
|
||||||
title: params.title,
|
const metric = (params.metric || "—").replace(/'/g, "''");
|
||||||
status: (params.status as ShipStatus) || "planned",
|
|
||||||
timestamp: now,
|
|
||||||
metric: params.metric || "—",
|
|
||||||
prLink: params.prLink || "#pr",
|
|
||||||
deployLink: params.deployLink || "#deploy",
|
|
||||||
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 {
|
try {
|
||||||
const dbResult = await pi.exec("bash", ["-c",
|
const raw = await dbExec(pi,
|
||||||
`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\\\"'"`
|
`INSERT INTO ships (title, status, metric) VALUES ('${title}', '${status}', '${metric}') RETURNING id`
|
||||||
], { timeout: 15000 });
|
);
|
||||||
const dbId = parseInt((dbResult.stdout || "").trim());
|
const id = parseInt(raw.trim()) || 0;
|
||||||
if (dbId > 0) entry.id = dbId;
|
return { content: [{ type: "text", text: `Ship #${id} added: "${params.title}" [${status}]` }] };
|
||||||
} catch { /* DB write failed, local state still updated */ }
|
} catch (err: any) {
|
||||||
|
return { content: [{ type: "text", text: `DB ERROR adding ship: ${err.message}` }], isError: true };
|
||||||
return {
|
}
|
||||||
content: [{ type: "text", text: `Ship #${entry.id} added: "${entry.title}" [${entry.status}]` }],
|
|
||||||
details: { state: { ...state, ships: [...state.ships] } },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "update": {
|
case "update": {
|
||||||
if (params.id === undefined) {
|
if (params.id === undefined) {
|
||||||
return {
|
return { content: [{ type: "text", text: "Error: id required for update" }], isError: true };
|
||||||
content: [{ type: "text", text: "Error: id required for update" }],
|
|
||||||
details: { state: { ...state }, error: "id required" },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const ship = state.ships.find(s => s.id === params.id);
|
|
||||||
if (!ship) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Ship #${params.id} not found` }],
|
|
||||||
details: { state: { ...state }, error: `#${params.id} not found` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (params.status) ship.status = params.status as ShipStatus;
|
|
||||||
if (params.metric) ship.metric = params.metric;
|
|
||||||
if (params.prLink) ship.prLink = params.prLink;
|
|
||||||
if (params.deployLink) ship.deployLink = params.deployLink;
|
|
||||||
if (params.loomLink) ship.loomLink = params.loomLink;
|
|
||||||
ship.timestamp = now;
|
|
||||||
|
|
||||||
// Persist update to PostgreSQL
|
|
||||||
const setClauses: string[] = [];
|
const setClauses: string[] = [];
|
||||||
if (params.status) setClauses.push(`status='${params.status}'`);
|
if (params.status) setClauses.push(`status='${params.status}'`);
|
||||||
if (params.metric) setClauses.push(`metric='${(params.metric || "").replace(/'/g, "''")}'`);
|
if (params.metric) setClauses.push(`metric='${params.metric.replace(/'/g, "''")}'`);
|
||||||
setClauses.push("updated_at=now()");
|
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 {
|
try {
|
||||||
content: [{ type: "text", text: `Ship #${ship.id} updated: "${ship.title}" [${ship.status}]` }],
|
const raw = await dbExec(pi,
|
||||||
details: { state: { ...state, ships: [...state.ships] } },
|
`UPDATE ships SET ${setClauses.join(", ")} WHERE id=${params.id} RETURNING id, title, status`
|
||||||
};
|
);
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return { content: [{ type: "text", text: `Ship #${params.id} not found in DB` }], isError: true };
|
||||||
|
}
|
||||||
|
const p = raw.trim().split("|||");
|
||||||
|
return { content: [{ type: "text", text: `Ship #${p[0]} updated: "${p[1]}" [${p[2]}]` }] };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { content: [{ type: "text", text: `DB ERROR updating ship: ${err.message}` }], isError: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
if (state.ships.length === 0) {
|
try {
|
||||||
return {
|
const ships = await fetchShips(pi);
|
||||||
content: [{ type: "text", text: "No ships logged yet." }],
|
if (ships.length === 0) {
|
||||||
details: { state: { ...state } },
|
return { content: [{ type: "text", text: "No ships in DB." }] };
|
||||||
};
|
}
|
||||||
|
const lines = ships.map(s =>
|
||||||
|
`#${s.id} [${s.status.toUpperCase()}] ${s.title} — ${s.metric}`
|
||||||
|
);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { content: [{ type: "text", text: `DB ERROR listing ships: ${err.message}` }], isError: true };
|
||||||
}
|
}
|
||||||
const lines = state.ships.map(s =>
|
|
||||||
`#${s.id} [${s.status.toUpperCase()}] ${s.title} (${s.timestamp}) — ${s.metric}`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
|
||||||
details: { state: { ...state } },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], isError: true };
|
||||||
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
|
|
||||||
details: { state: { ...state } },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -380,30 +293,15 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
renderResult(result, { expanded }, theme) {
|
||||||
const details = result.details as { state?: ShipLogState; error?: string } | undefined;
|
const text = result.content[0];
|
||||||
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
const msg = text?.type === "text" ? text.text : "";
|
||||||
|
if (result.isError) return new Text(theme.fg("error", msg), 0, 0);
|
||||||
const st = details?.state;
|
return new Text(theme.fg("success", msg), 0, 0);
|
||||||
if (!st || st.ships.length === 0) return new Text(theme.fg("dim", "No ships"), 0, 0);
|
|
||||||
|
|
||||||
const shipped = st.ships.filter(s => s.status === "shipped").length;
|
|
||||||
const total = st.ships.length;
|
|
||||||
let text = theme.fg("success", `${shipped}/${total} shipped`);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
for (const s of st.ships) {
|
|
||||||
const badge = s.status === "shipped" ? theme.fg("success", "✓")
|
|
||||||
: s.status === "shipping" ? theme.fg("warning", "●")
|
|
||||||
: theme.fg("dim", "○");
|
|
||||||
text += `\n ${badge} ${theme.fg("accent", `#${s.id}`)} ${theme.fg("muted", s.title)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
// TOOL: calvana_oops
|
// TOOL: calvana_oops — ALL operations go directly to DB
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
@@ -413,64 +311,41 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
parameters: OopsParams,
|
parameters: OopsParams,
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||||
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
|
|
||||||
|
|
||||||
switch (params.action) {
|
switch (params.action) {
|
||||||
case "add": {
|
case "add": {
|
||||||
if (!params.description) {
|
if (!params.description) {
|
||||||
return {
|
return { content: [{ type: "text", text: "Error: description required" }], isError: true };
|
||||||
content: [{ type: "text", text: "Error: description required" }],
|
|
||||||
details: { state: { ...state }, error: "description required" },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const entry: OopsEntry = {
|
const desc = params.description.replace(/'/g, "''");
|
||||||
id: state.nextOopsId++,
|
const fixTime = (params.fixTime || "—").replace(/'/g, "''");
|
||||||
description: params.description,
|
const commitLink = (params.commitLink || "#commit").replace(/'/g, "''");
|
||||||
fixTime: params.fixTime || "—",
|
|
||||||
commitLink: params.commitLink || "#commit",
|
|
||||||
timestamp: now,
|
|
||||||
};
|
|
||||||
state.oops.push(entry);
|
|
||||||
|
|
||||||
// Persist to PostgreSQL
|
|
||||||
const oopsDesc = entry.description.replace(/'/g, "''");
|
|
||||||
const oopsTime = (entry.fixTime || "-").replace(/'/g, "''");
|
|
||||||
const oopsCommit = (entry.commitLink || "#commit").replace(/'/g, "''");
|
|
||||||
try {
|
try {
|
||||||
const dbResult = await pi.exec("bash", ["-c",
|
const raw = await dbExec(pi,
|
||||||
`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 oops (description, fix_time, commit_link) VALUES (\\x27${oopsDesc}\\x27, \\x27${oopsTime}\\x27, \\x27${oopsCommit}\\x27) RETURNING id\\\"'"`
|
`INSERT INTO oops (description, fix_time, commit_link) VALUES ('${desc}', '${fixTime}', '${commitLink}') RETURNING id`
|
||||||
], { timeout: 15000 });
|
);
|
||||||
const dbId = parseInt((dbResult.stdout || "").trim());
|
const id = parseInt(raw.trim()) || 0;
|
||||||
if (dbId > 0) entry.id = dbId;
|
return { content: [{ type: "text", text: `Oops #${id}: "${params.description}" (fixed in ${params.fixTime || "—"})` }] };
|
||||||
} catch { /* DB write failed, local state still updated */ }
|
} catch (err: any) {
|
||||||
|
return { content: [{ type: "text", text: `DB ERROR adding oops: ${err.message}` }], isError: true };
|
||||||
return {
|
}
|
||||||
content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }],
|
|
||||||
details: { state: { ...state, oops: [...state.oops] } },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
if (state.oops.length === 0) {
|
try {
|
||||||
return {
|
const oops = await fetchOops(pi);
|
||||||
content: [{ type: "text", text: "No oops entries. Clean run so far." }],
|
if (oops.length === 0) {
|
||||||
details: { state: { ...state } },
|
return { content: [{ type: "text", text: "No oops entries. Clean run so far." }] };
|
||||||
};
|
}
|
||||||
|
const lines = oops.map(o => `#${o.id} ${o.description} — fixed in ${o.fixTime}`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { content: [{ type: "text", text: `DB ERROR listing oops: ${err.message}` }], isError: true };
|
||||||
}
|
}
|
||||||
const lines = state.oops.map(o =>
|
|
||||||
`#${o.id} ${o.description} — fixed in ${o.fixTime}`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
|
||||||
details: { state: { ...state } },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], isError: true };
|
||||||
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
|
|
||||||
details: { state: { ...state } },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -482,15 +357,15 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
renderResult(result, _options, theme) {
|
||||||
const details = result.details as { state?: ShipLogState; error?: string } | undefined;
|
|
||||||
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
||||||
const text = result.content[0];
|
const text = result.content[0];
|
||||||
return new Text(theme.fg("warning", text?.type === "text" ? text.text : ""), 0, 0);
|
const msg = text?.type === "text" ? text.text : "";
|
||||||
|
if (result.isError) return new Text(theme.fg("error", msg), 0, 0);
|
||||||
|
return new Text(theme.fg("warning", msg), 0, 0);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
// TOOL: calvana_deploy
|
// TOOL: calvana_deploy — Reads ONLY from DB, never from memory
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
@@ -502,14 +377,13 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
||||||
onUpdate?.({ content: [{ type: "text", text: "Querying database for full ship log..." }] });
|
onUpdate?.({ content: [{ type: "text", text: "Querying database for full ship log..." }] });
|
||||||
|
|
||||||
// ── Build a helper script on the remote server to avoid quoting hell ──
|
// NOTE: We intentionally EXCLUDE the 'details' column from ships.
|
||||||
// This is the ONLY way to reliably run psql inside docker inside incus inside ssh.
|
// It contains multiline HTML that breaks the ||| line-based parser.
|
||||||
// Previous approach with nested escaping silently returned empty results.
|
// The metric column is sufficient for the live page cards.
|
||||||
const sshBase = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
|
||||||
const HELPER_SCRIPT = `
|
const HELPER_SCRIPT = `
|
||||||
PG_CONTAINER=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker ps --format '{{.Names}}' | grep dokploy-postgres")
|
PG_CONTAINER=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker ps --format '{{.Names}}' | grep dokploy-postgres")
|
||||||
if [ -z "$PG_CONTAINER" ]; then echo "ERR:NO_PG_CONTAINER"; exit 1; fi
|
if [ -z "$PG_CONTAINER" ]; then echo "ERR:NO_PG_CONTAINER"; exit 1; fi
|
||||||
SHIPS=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker exec $PG_CONTAINER psql -U dokploy -d calvana -t -A -F '|||' -c \\"SELECT id, title, status, COALESCE(metric,'-'), COALESCE(details,' '), created_at::text, COALESCE(updated_at::text, created_at::text) FROM ships ORDER BY id\\"")
|
SHIPS=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker exec $PG_CONTAINER psql -U dokploy -d calvana -t -A -F '|||' -c \\"SELECT id, title, status, COALESCE(metric,'-'), created_at::text, COALESCE(updated_at::text, created_at::text) FROM ships ORDER BY id\\"")
|
||||||
OOPS=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker exec $PG_CONTAINER psql -U dokploy -d calvana -t -A -F '|||' -c \\"SELECT id, description, COALESCE(fix_time,'-'), COALESCE(commit_link,'#commit'), created_at::text FROM oops ORDER BY id\\"")
|
OOPS=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker exec $PG_CONTAINER psql -U dokploy -d calvana -t -A -F '|||' -c \\"SELECT id, description, COALESCE(fix_time,'-'), COALESCE(commit_link,'#commit'), created_at::text FROM oops ORDER BY id\\"")
|
||||||
echo "===SHIPS==="
|
echo "===SHIPS==="
|
||||||
echo "$SHIPS"
|
echo "$SHIPS"
|
||||||
@@ -519,15 +393,13 @@ echo "===END==="
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Run the helper script via SSH to get ALL ships + oops from DB
|
|
||||||
const dbResult = await pi.exec("bash", ["-c",
|
const dbResult = await pi.exec("bash", ["-c",
|
||||||
`${sshBase} 'bash -s' << 'DBEOF'\n${HELPER_SCRIPT}\nDBEOF`
|
`${SSH_BASE} 'bash -s' << 'DBEOF'\n${HELPER_SCRIPT}\nDBEOF`
|
||||||
], { signal, timeout: 30000 });
|
], { signal, timeout: 30000 });
|
||||||
|
|
||||||
if (dbResult.code !== 0) {
|
if (dbResult.code !== 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `DB query failed (code ${dbResult.code}): ${dbResult.stderr}\nstdout: ${dbResult.stdout?.slice(0, 200)}` }],
|
content: [{ type: "text", text: `DB query failed (code ${dbResult.code}): ${dbResult.stderr}\nstdout: ${dbResult.stdout?.slice(0, 200)}` }],
|
||||||
details: { state: { ...state }, error: dbResult.stderr },
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -535,29 +407,22 @@ echo "===END==="
|
|||||||
const output = dbResult.stdout || "";
|
const output = dbResult.stdout || "";
|
||||||
if (output.includes("ERR:NO_PG_CONTAINER")) {
|
if (output.includes("ERR:NO_PG_CONTAINER")) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `ABORT: PostgreSQL container not found. Refusing to deploy.` }],
|
content: [{ type: "text", text: `ABORT: PostgreSQL container not found.` }],
|
||||||
details: { state: { ...state }, error: "PG container not found" },
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Parse the structured output
|
|
||||||
const shipsSection = output.split("===SHIPS===")[1]?.split("===OOPS===")[0]?.trim() || "";
|
const shipsSection = output.split("===SHIPS===")[1]?.split("===OOPS===")[0]?.trim() || "";
|
||||||
const oopsSection = output.split("===OOPS===")[1]?.split("===END===")[0]?.trim() || "";
|
const oopsSection = output.split("===OOPS===")[1]?.split("===END===")[0]?.trim() || "";
|
||||||
|
|
||||||
const dbShips: Array<{ id: number; title: string; status: string; metric: string; details: string; created: string; updated: string }> = [];
|
const dbShips: Array<{ id: number; title: string; status: string; metric: string; created: string; updated: string }> = [];
|
||||||
for (const line of shipsSection.split("\n")) {
|
for (const line of shipsSection.split("\n")) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
const parts = line.split("|||");
|
const parts = line.split("|||");
|
||||||
if (parts.length >= 6) {
|
if (parts.length >= 5) {
|
||||||
dbShips.push({
|
dbShips.push({
|
||||||
id: parseInt(parts[0]),
|
id: parseInt(parts[0]), title: parts[1], status: parts[2],
|
||||||
title: parts[1],
|
metric: parts[3], created: parts[4], updated: parts[5] || parts[4],
|
||||||
status: parts[2],
|
|
||||||
metric: parts[3],
|
|
||||||
details: parts[4],
|
|
||||||
created: parts[5],
|
|
||||||
updated: parts[6] || parts[5],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,69 +433,56 @@ echo "===END==="
|
|||||||
const parts = line.split("|||");
|
const parts = line.split("|||");
|
||||||
if (parts.length >= 4) {
|
if (parts.length >= 4) {
|
||||||
dbOops.push({
|
dbOops.push({
|
||||||
id: parseInt(parts[0]),
|
id: parseInt(parts[0]), description: parts[1],
|
||||||
description: parts[1],
|
fixTime: parts[2], commitLink: parts[3], created: parts[4] || "",
|
||||||
fixTime: parts[2],
|
|
||||||
commitLink: parts[3],
|
|
||||||
created: parts[4] || "",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SAFETY GATE: refuse to deploy if DB returned 0 ships ──
|
|
||||||
// The DB has 48+ entries. If we get 0, the query broke silently.
|
|
||||||
if (dbShips.length === 0) {
|
if (dbShips.length === 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `ABORT: DB query returned 0 ships. This would wipe the live site.\nRaw output (first 500 chars): ${output.slice(0, 500)}\n\nRefusing to deploy. Fix the DB query first.` }],
|
content: [{ type: "text", text: `ABORT: DB returned 0 ships. Refusing to deploy.\nRaw: ${output.slice(0, 500)}` }],
|
||||||
details: { state: { ...state }, error: "0 ships from DB — refusing to deploy" },
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate?.({ content: [{ type: "text", text: `Found ${dbShips.length} ships + ${dbOops.length} oops from DB. Generating HTML...` }] });
|
onUpdate?.({ content: [{ type: "text", text: `Found ${dbShips.length} ships + ${dbOops.length} oops. Generating HTML...` }] });
|
||||||
|
|
||||||
// 3. Generate HTML from DB data
|
|
||||||
const liveHtml = generateLivePageFromDb(dbShips, dbOops);
|
const liveHtml = generateLivePageFromDb(dbShips, dbOops);
|
||||||
|
|
||||||
if (params.dryRun) {
|
if (params.dryRun) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Dry run — ${dbShips.length} ships, ${dbOops.length} oops, ${liveHtml.length} bytes 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 ${dbShips.length} ships to server...` }] });
|
onUpdate?.({ content: [{ type: "text", text: `Deploying ${dbShips.length} ships...` }] });
|
||||||
|
|
||||||
// 4. Deploy via base64
|
|
||||||
const b64Html = Buffer.from(liveHtml).toString("base64");
|
const b64Html = Buffer.from(liveHtml).toString("base64");
|
||||||
const deployResult = await pi.exec("bash", ["-c",
|
const deployResult = await pi.exec("bash", ["-c",
|
||||||
`${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
|
`${SSH_BASE} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
|
||||||
], { signal, timeout: 30000 });
|
], { signal, timeout: 30000 });
|
||||||
|
|
||||||
if (deployResult.code !== 0) {
|
if (deployResult.code !== 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Deploy failed: ${deployResult.stderr}` }],
|
content: [{ type: "text", text: `Deploy write failed: ${deployResult.stderr}` }],
|
||||||
details: { state: { ...state }, error: deployResult.stderr },
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Rebuild and update docker service
|
|
||||||
const rebuildResult = await pi.exec("bash", ["-c",
|
const rebuildResult = await pi.exec("bash", ["-c",
|
||||||
`${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'"`
|
`${SSH_BASE} "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 });
|
], { signal, timeout: 60000 });
|
||||||
|
|
||||||
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
state.lastDeployed = now;
|
lastDeployed = now;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${dbShips.length} ships + ${dbOops.length} oops from database\n${rebuildResult.stdout}` }],
|
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${dbShips.length} ships + ${dbOops.length} oops from database\n${rebuildResult.stdout}` }],
|
||||||
details: { state: { ...state, lastDeployed: now } },
|
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Deploy error: ${err.message}` }],
|
content: [{ type: "text", text: `Deploy error: ${err.message}` }],
|
||||||
details: { state: { ...state }, error: err.message },
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -641,26 +493,35 @@ echo "===END==="
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
renderResult(result, _options, theme) {
|
||||||
const details = result.details as { error?: string } | undefined;
|
if (result.isError) {
|
||||||
if (details?.error) return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
const text = result.content[0];
|
||||||
|
return new Text(theme.fg("error", `✗ ${text?.type === "text" ? text.text : "failed"}`), 0, 0);
|
||||||
|
}
|
||||||
return new Text(theme.fg("success", `✓ Live at https://${DEPLOY_CONFIG.domain}/live`), 0, 0);
|
return new Text(theme.fg("success", `✓ Live at https://${DEPLOY_CONFIG.domain}/live`), 0, 0);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
// COMMAND: /ships
|
// COMMAND: /ships — reads directly from DB
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pi.registerCommand("ships", {
|
pi.registerCommand("ships", {
|
||||||
description: "View current Calvana shipping log",
|
description: "View current Calvana shipping log (from DB)",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
if (!ctx.hasUI) {
|
if (!ctx.hasUI) return;
|
||||||
ctx.ui.notify("Requires interactive mode", "error");
|
|
||||||
return;
|
let ships: DbShip[] = [];
|
||||||
|
let oops: DbOops[] = [];
|
||||||
|
let dbError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
[ships, oops] = await Promise.all([fetchShips(pi), fetchOops(pi)]);
|
||||||
|
} catch (err: any) {
|
||||||
|
dbError = err.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
||||||
return new ShipLogComponent(state, theme, () => done());
|
return new ShipLogComponent(ships, oops, lastDeployed, dbError, theme, () => done());
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -674,8 +535,6 @@ echo "===END==="
|
|||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
const ok = await ctx.ui.confirm("Deploy?", `Push ship log to https://${DEPLOY_CONFIG.domain}/live?`);
|
const ok = await ctx.ui.confirm("Deploy?", `Push ship log to https://${DEPLOY_CONFIG.domain}/live?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
// Queue a deploy via the LLM
|
|
||||||
pi.sendUserMessage("Use calvana_deploy to push the current ship log to the live site.", { deliverAs: "followUp" });
|
pi.sendUserMessage("Use calvana_deploy to push the current ship log to the live site.", { deliverAs: "followUp" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -686,14 +545,20 @@ echo "===END==="
|
|||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class ShipLogComponent {
|
class ShipLogComponent {
|
||||||
private state: ShipLogState;
|
private ships: DbShip[];
|
||||||
|
private oops: DbOops[];
|
||||||
|
private lastDeployed: string | null;
|
||||||
|
private dbError: string | null;
|
||||||
private theme: Theme;
|
private theme: Theme;
|
||||||
private onClose: () => void;
|
private onClose: () => void;
|
||||||
private cachedWidth?: number;
|
private cachedWidth?: number;
|
||||||
private cachedLines?: string[];
|
private cachedLines?: string[];
|
||||||
|
|
||||||
constructor(state: ShipLogState, theme: Theme, onClose: () => void) {
|
constructor(ships: DbShip[], oops: DbOops[], lastDeployed: string | null, dbError: string | null, theme: Theme, onClose: () => void) {
|
||||||
this.state = state;
|
this.ships = ships;
|
||||||
|
this.oops = oops;
|
||||||
|
this.lastDeployed = lastDeployed;
|
||||||
|
this.dbError = dbError;
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.onClose = onClose;
|
this.onClose = onClose;
|
||||||
}
|
}
|
||||||
@@ -713,53 +578,48 @@ class ShipLogComponent {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(truncateToWidth(
|
lines.push(truncateToWidth(
|
||||||
th.fg("borderMuted", "─".repeat(3)) +
|
th.fg("borderMuted", "─".repeat(3)) +
|
||||||
th.fg("accent", " 🚀 Calvana Ship Log ") +
|
th.fg("accent", " 🚀 Calvana Ship Log (DB) ") +
|
||||||
th.fg("borderMuted", "─".repeat(Math.max(0, width - 26))),
|
th.fg("borderMuted", "─".repeat(Math.max(0, width - 30))),
|
||||||
width
|
width
|
||||||
));
|
));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// Ships
|
if (this.dbError) {
|
||||||
if (this.state.ships.length === 0) {
|
lines.push(truncateToWidth(` ${th.fg("error", `DB ERROR: ${this.dbError}`)}`, width));
|
||||||
|
lines.push("");
|
||||||
|
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
||||||
|
lines.push("");
|
||||||
|
this.cachedWidth = width;
|
||||||
|
this.cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ships.length === 0) {
|
||||||
lines.push(truncateToWidth(` ${th.fg("dim", "No ships yet.")}`, width));
|
lines.push(truncateToWidth(` ${th.fg("dim", "No ships yet.")}`, width));
|
||||||
} else {
|
} else {
|
||||||
const shipped = this.state.ships.filter(s => s.status === "shipped").length;
|
const shipped = this.ships.filter(s => s.status === "shipped").length;
|
||||||
lines.push(truncateToWidth(
|
lines.push(truncateToWidth(` ${th.fg("muted", `${shipped}/${this.ships.length} shipped`)}`, width));
|
||||||
` ${th.fg("muted", `${shipped}/${this.state.ships.length} shipped`)}`,
|
|
||||||
width
|
|
||||||
));
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
for (const s of this.ships) {
|
||||||
for (const s of this.state.ships) {
|
|
||||||
const badge = s.status === "shipped" ? th.fg("success", "✓ SHIPPED ")
|
const badge = s.status === "shipped" ? th.fg("success", "✓ SHIPPED ")
|
||||||
: s.status === "shipping" ? th.fg("warning", "● SHIPPING")
|
: s.status === "shipping" ? th.fg("warning", "● SHIPPING")
|
||||||
: th.fg("dim", "○ PLANNED ");
|
: th.fg("dim", "○ PLANNED ");
|
||||||
lines.push(truncateToWidth(
|
lines.push(truncateToWidth(` ${badge} ${th.fg("accent", `#${s.id}`)} ${th.fg("text", s.title)}`, width));
|
||||||
` ${badge} ${th.fg("accent", `#${s.id}`)} ${th.fg("text", s.title)}`,
|
lines.push(truncateToWidth(` ${th.fg("dim", s.created)} · ${th.fg("dim", s.metric)}`, width));
|
||||||
width
|
|
||||||
));
|
|
||||||
lines.push(truncateToWidth(
|
|
||||||
` ${th.fg("dim", s.timestamp)} · ${th.fg("dim", s.metric)}`,
|
|
||||||
width
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Oops
|
if (this.oops.length > 0) {
|
||||||
if (this.state.oops.length > 0) {
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(truncateToWidth(` ${th.fg("warning", "💥 Oops Log")}`, width));
|
lines.push(truncateToWidth(` ${th.fg("warning", "💥 Oops Log")}`, width));
|
||||||
for (const o of this.state.oops) {
|
for (const o of this.oops) {
|
||||||
lines.push(truncateToWidth(
|
lines.push(truncateToWidth(` ${th.fg("error", "─")} ${th.fg("muted", o.description)} ${th.fg("dim", `(${o.fixTime})`)}`, width));
|
||||||
` ${th.fg("error", "─")} ${th.fg("muted", o.description)} ${th.fg("dim", `(${o.fixTime})`)}`,
|
|
||||||
width
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
if (this.state.lastDeployed) {
|
if (this.lastDeployed) {
|
||||||
lines.push(truncateToWidth(` ${th.fg("dim", `Last deployed: ${this.state.lastDeployed}`)}`, width));
|
lines.push(truncateToWidth(` ${th.fg("dim", `Last deployed: ${this.lastDeployed}`)}`, width));
|
||||||
}
|
}
|
||||||
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -775,27 +635,22 @@ class ShipLogComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// Interface re-exports for the component
|
||||||
// HTML GENERATOR — Builds the /live page from current state
|
interface DbShip { id: number; title: string; status: string; metric: string; created: string; }
|
||||||
// ════════════════════════════════════════════════════════════════════
|
interface DbOops { id: number; description: string; fixTime: string; commitLink: string; created: string; }
|
||||||
|
|
||||||
// Keep the old function signature for backward compat but it's no longer called by deploy
|
// ════════════════════════════════════════════════════════════════════
|
||||||
function generateLivePageHtml(state: ShipLogState): string {
|
// HTML GENERATOR
|
||||||
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 }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateLivePageFromDb(
|
function generateLivePageFromDb(
|
||||||
ships: Array<{ id: number; title: string; status: string; metric: string; details: string; created: string; updated: string }>,
|
ships: Array<{ id: number; title: string; status: string; metric: string; created: string; updated: string }>,
|
||||||
oops: Array<{ id: number; description: string; fixTime: string; commitLink: string; created: string }>
|
oops: Array<{ id: number; description: string; fixTime: string; commitLink: string; created: string }>
|
||||||
): string {
|
): string {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const shipped = ships.filter(s => s.status === "shipped").length;
|
const shipped = ships.filter(s => s.status === "shipped").length;
|
||||||
const shipping = ships.filter(s => s.status === "shipping").length;
|
const shipping = ships.filter(s => s.status === "shipping").length;
|
||||||
|
|
||||||
// Group ships by date (newest first)
|
|
||||||
const shipsByDate = new Map<string, typeof ships>();
|
const shipsByDate = new Map<string, typeof ships>();
|
||||||
for (const s of [...ships].reverse()) {
|
for (const s of [...ships].reverse()) {
|
||||||
const date = s.created.split(" ")[0] || s.created.split("T")[0] || "Unknown";
|
const date = s.created.split(" ")[0] || s.created.split("T")[0] || "Unknown";
|
||||||
@@ -813,33 +668,23 @@ function generateLivePageFromDb(
|
|||||||
let shipSections = "";
|
let shipSections = "";
|
||||||
for (const [date, dateShips] of shipsByDate) {
|
for (const [date, dateShips] of shipsByDate) {
|
||||||
const cards = dateShips.map(s => {
|
const cards = dateShips.map(s => {
|
||||||
const badgeClass = s.status === "shipped" ? "badge-shipped"
|
const badgeClass = s.status === "shipped" ? "badge-shipped" : s.status === "shipping" ? "badge-shipping" : "badge-planned";
|
||||||
: s.status === "shipping" ? "badge-shipping"
|
|
||||||
: "badge-planned";
|
|
||||||
const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1);
|
const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1);
|
||||||
|
|
||||||
// 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">
|
return ` <div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-id">#${s.id}</span>
|
<span class="card-id">#${s.id}</span>
|
||||||
<span class="card-title">${escapeHtml(s.title)}</span>
|
<span class="card-title">${escapeHtml(s.title)}</span>
|
||||||
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="metric">${escapeHtml(s.metric)}</p>${detailsBlock}
|
<p class="metric">${escapeHtml(s.metric)}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("\n");
|
}).join("\n");
|
||||||
|
|
||||||
shipSections += `
|
shipSections += `
|
||||||
<section class="section day-section">
|
<section class="section day-section">
|
||||||
<h2 class="day-header">${formatDate(date)} <span class="day-count">${dateShips.length} ship${dateShips.length !== 1 ? "s" : ""}</span></h2>
|
<h2 class="day-header">${formatDate(date)} <span class="day-count">${dateShips.length} ship${dateShips.length !== 1 ? "s" : ""}</span></h2>
|
||||||
<div class="card-grid">
|
<div class="card-grid">\n${cards}\n </div>
|
||||||
${cards}
|
|
||||||
</div>
|
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,9 +780,7 @@ ${shipSections}
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<h2>Oops Log</h2>
|
<h2>Oops Log</h2>
|
||||||
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
|
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
|
||||||
<div class="oops-log">
|
<div class="oops-log">\n${oopsEntries}\n </div>
|
||||||
${oopsEntries}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@@ -951,19 +794,11 @@ ${oopsEntries}
|
|||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
.replace(/</g, "<")
|
.replace(/"/g, """).replace(/'/g, "'")
|
||||||
.replace(/>/g, ">")
|
.replace(/\u2014/g, "—").replace(/\u2013/g, "–")
|
||||||
.replace(/"/g, """)
|
.replace(/\u2019/g, "’").replace(/\u2018/g, "‘")
|
||||||
.replace(/'/g, "'")
|
.replace(/\u201c/g, "“").replace(/\u201d/g, "”")
|
||||||
.replace(/\u2014/g, "—") // em dash
|
.replace(/\u2026/g, "…").replace(/\u2192/g, "→")
|
||||||
.replace(/\u2013/g, "–") // en dash
|
.replace(/\u00a3/g, "£").replace(/\u00d7/g, "×");
|
||||||
.replace(/\u2019/g, "’") // right single quote
|
|
||||||
.replace(/\u2018/g, "‘") // left single quote
|
|
||||||
.replace(/\u201c/g, "“") // left double quote
|
|
||||||
.replace(/\u201d/g, "”") // right double quote
|
|
||||||
.replace(/\u2026/g, "…") // ellipsis
|
|
||||||
.replace(/\u2192/g, "→") // right arrow
|
|
||||||
.replace(/\u00a3/g, "£") // pound sign
|
|
||||||
.replace(/\u00d7/g, "×"); // multiplication sign
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 86 KiB |
@@ -157,61 +157,57 @@ export default function HomePage() {
|
|||||||
We built the missing system between “I'll donate” and the money arriving.
|
We built the missing system between “I'll donate” and the money arriving.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Stacking cards — sticky scroll effect */}
|
{/* Stacking cards — all sticky siblings in ONE parent */}
|
||||||
<div className="mt-14">
|
<div className="mt-14">
|
||||||
{PERSONAS.map((p, i) => {
|
{PERSONAS.flatMap((p, i) => {
|
||||||
const imgFirst = i % 2 === 0
|
const imgFirst = i % 2 === 0
|
||||||
return (
|
const els = [
|
||||||
<div key={p.slug} className={i < PERSONAS.length - 1 ? "pb-36" : ""}>
|
<div
|
||||||
<div
|
key={p.slug}
|
||||||
className="sticky will-change-transform"
|
className="sticky will-change-transform"
|
||||||
style={{ top: `${72 + i * 16}px`, zIndex: 10 + i * 10 }}
|
style={{ top: `${72 + i * 20}px`, zIndex: i + 1 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/for/${p.slug}`}
|
||||||
|
className="group block bg-white border border-gray-100 overflow-hidden"
|
||||||
>
|
>
|
||||||
<Link
|
<div className={`flex flex-col ${imgFirst ? "md:flex-row" : "md:flex-row-reverse"}`}>
|
||||||
href={`/for/${p.slug}`}
|
<div className="md:w-7/12 relative overflow-hidden min-h-[220px] md:min-h-[400px]">
|
||||||
className="group block bg-white shadow-[0_2px_8px_rgba(0,0,0,0.08)] overflow-hidden"
|
<Image
|
||||||
>
|
src={p.image}
|
||||||
<div className={`flex flex-col ${imgFirst ? "md:flex-row" : "md:flex-row-reverse"}`}>
|
alt={p.scenario}
|
||||||
{/* Image — ~58%, fills full card height */}
|
fill
|
||||||
<div className="md:w-7/12 relative overflow-hidden min-h-[220px] md:min-h-[400px]">
|
className="object-cover"
|
||||||
<Image
|
sizes="(max-width: 768px) 100vw, 58vw"
|
||||||
src={p.image}
|
/>
|
||||||
alt={p.scenario}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 768px) 100vw, 58vw"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text — ~42% */}
|
|
||||||
<div className="md:w-5/12 p-8 md:p-12 lg:p-14 flex flex-col justify-center">
|
|
||||||
<h3 className="text-lg md:text-xl font-black text-gray-900 group-hover:text-promise-blue transition-colors">
|
|
||||||
{p.scenario}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Pain stats */}
|
|
||||||
<div className="mt-5 space-y-1">
|
|
||||||
<p className="text-xl md:text-2xl lg:text-3xl font-black text-gray-400 tracking-tight leading-tight">
|
|
||||||
{p.stat1}
|
|
||||||
</p>
|
|
||||||
<p className="text-xl md:text-2xl lg:text-3xl font-black text-gray-900 tracking-tight leading-tight">
|
|
||||||
{p.stat2}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 leading-relaxed mt-5">
|
|
||||||
{p.copy}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-xs font-semibold text-promise-blue mt-6 group-hover:underline">
|
|
||||||
{p.cta} →
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<div className="md:w-5/12 p-8 md:p-12 lg:p-14 flex flex-col justify-center">
|
||||||
</div>
|
<h3 className="text-lg md:text-xl font-black text-gray-900 group-hover:text-promise-blue transition-colors">
|
||||||
</div>
|
{p.scenario}
|
||||||
)
|
</h3>
|
||||||
|
<div className="mt-5 space-y-1">
|
||||||
|
<p className="text-xl md:text-2xl lg:text-3xl font-black text-gray-400 tracking-tight leading-tight">
|
||||||
|
{p.stat1}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl md:text-2xl lg:text-3xl font-black text-gray-900 tracking-tight leading-tight">
|
||||||
|
{p.stat2}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed mt-5">
|
||||||
|
{p.copy}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-promise-blue mt-6 group-hover:underline">
|
||||||
|
{p.cta} →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
if (i < PERSONAS.length - 1) {
|
||||||
|
els.push(<div key={`sp-${i}`} className="h-4" aria-hidden="true" />)
|
||||||
|
}
|
||||||
|
return els
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
screenshots/fidya-current/fidya-feeding.jpg
Normal file
|
After Width: | Height: | Size: 752 KiB |
BIN
screenshots/fidya-current/fidya-quran-elder.jpg
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
screenshots/fidya-current/kaffarah-community.jpg
Normal file
|
After Width: | Height: | Size: 778 KiB |
BIN
screenshots/fidya-kaffarah-full.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
screenshots/homepage-full.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
screenshots/make-it-right-full.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
screenshots/one-eats-full.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
screenshots/ramadan-full.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
screenshots/reference/oneeats-slide-1.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
screenshots/reference/oneeats-slide-2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
screenshots/reference/ramadan-cr-fidya-new.jpg
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
screenshots/reference/ramadan-cr-hunger6-zakat.jpg
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
screenshots/reference/ramadan-cr-wrong-1.jpg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
screenshots/reference/ramadan-cr-wrong-4.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
screenshots/reference/spoonfeed-hero-6.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
screenshots/reference/spoonfeed-hero-7.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |