fix headline rhythm + wider hero image + 10x faster deploys
HEADLINE: - 3 balanced lines: 'Turn I'll donate' / 'into money' / 'in the bank.' - Removed that orphaned 'money' on its own line - <br className='hidden lg:block'> controls breaks on desktop only IMAGE: - Hero container: max-w-5xl -> max-w-7xl (image 25% wider) - Stat strip widened to match - Much more of the gala scene visible, phone prominent DEPLOY SPEED (deploy.sh): - Persistent /opt/pnpl/ build dir (no temp dir creation/deletion) - BuildKit with cache mounts (npm + .next/cache) - No more docker builder prune / docker rmi (preserves cache!) - Installed docker-buildx v0.31.1 on server - Before: ~245s (4+ min) After: ~29s (cached) / ~136s (first) - Use: cd pledge-now-pay-later && bash deploy.sh
This commit is contained in:
@@ -118,10 +118,79 @@ export default function (pi: ExtensionAPI) {
|
|||||||
lastDeployed: null,
|
lastDeployed: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State reconstruction from session ──
|
// ── State reconstruction: DB first, session fallback ──
|
||||||
const reconstructState = (ctx: ExtensionContext) => {
|
const reconstructFromDb = async () => {
|
||||||
|
try {
|
||||||
|
const sshBase = `ssh -o ConnectTimeout=5 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
||||||
|
const pgContainer = "$(docker ps --format '{{.Names}}' | grep dokploy-postgres)";
|
||||||
|
|
||||||
|
const shipsResult = 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, title, status, COALESCE(metric, chr(45)), created_at::text FROM ships ORDER BY id\\\"'" 2>/dev/null`
|
||||||
|
], { timeout: 15000 });
|
||||||
|
|
||||||
|
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) => {
|
||||||
state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null };
|
state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null };
|
||||||
|
|
||||||
|
// Always try DB first — this is the source of truth
|
||||||
|
await reconstructFromDb();
|
||||||
|
|
||||||
|
// 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()) {
|
for (const entry of ctx.sessionManager.getBranch()) {
|
||||||
if (entry.type !== "message") continue;
|
if (entry.type !== "message") continue;
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
@@ -136,34 +205,38 @@ export default function (pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
reconstructState(ctx);
|
await reconstructState(ctx);
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
const theme = ctx.ui.theme;
|
const theme = ctx.ui.theme;
|
||||||
const shipCount = state.ships.length;
|
const shipCount = state.ships.length;
|
||||||
const shipped = state.ships.filter(s => s.status === "shipped").length;
|
const shipped = state.ships.filter(s => s.status === "shipped").length;
|
||||||
ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped`));
|
ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped (DB)`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
|
pi.on("session_switch", async (_event, ctx) => await reconstructState(ctx));
|
||||||
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
|
pi.on("session_fork", async (_event, ctx) => await reconstructState(ctx));
|
||||||
pi.on("session_tree", async (_event, ctx) => 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 shipContext = `
|
const shipContext = `
|
||||||
[Calvana Ship Log Extension Active]
|
[Calvana Ship Log Extension Active — DB-backed]
|
||||||
You have access to these tools for tracking work:
|
Ship log is persisted in PostgreSQL (calvana DB on dokploy-postgres).
|
||||||
- calvana_ship: Track shipping progress (add/update/list entries)
|
State survives across sessions — the DB is the source of truth, not session history.
|
||||||
- calvana_oops: Log mistakes and fixes
|
|
||||||
- calvana_deploy: Push updates to the live site at https://${DEPLOY_CONFIG.domain}/live
|
|
||||||
|
|
||||||
When you START working on a task, use calvana_ship to add or update it to "shipping".
|
Tools:
|
||||||
When you COMPLETE a task, update it to "shipped" with a metric.
|
- calvana_ship: Track shipping progress (add/update/list). Writes to DB.
|
||||||
If something BREAKS, log it with calvana_oops.
|
- calvana_oops: Log mistakes and fixes. Writes to DB.
|
||||||
After significant changes, use calvana_deploy to push updates live.
|
- calvana_deploy: Queries DB for ALL historical entries, generates HTML, deploys to https://${DEPLOY_CONFIG.domain}/live
|
||||||
|
|
||||||
Current ships: ${state.ships.length} (${state.ships.filter(s => s.status === "shipped").length} shipped)
|
Rules:
|
||||||
Current oops: ${state.oops.length}
|
- When you START working on a task, use calvana_ship to add or update it to "shipping".
|
||||||
|
- When you COMPLETE a task, update it to "shipped" with a metric.
|
||||||
|
- If something BREAKS, log it with calvana_oops.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.status === "shipped").length} shipped), ${state.oops.length} oops
|
||||||
`;
|
`;
|
||||||
return {
|
return {
|
||||||
systemPrompt: event.systemPrompt + shipContext,
|
systemPrompt: event.systemPrompt + shipContext,
|
||||||
@@ -358,6 +431,19 @@ Current oops: ${state.oops.length}
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
};
|
};
|
||||||
state.oops.push(entry);
|
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 {
|
||||||
|
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 oops (description, fix_time, commit_link) VALUES (\\x27${oopsDesc}\\x27, \\x27${oopsTime}\\x27, \\x27${oopsCommit}\\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 {
|
return {
|
||||||
content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }],
|
content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }],
|
||||||
details: { state: { ...state, oops: [...state.oops] } },
|
details: { state: { ...state, oops: [...state.oops] } },
|
||||||
@@ -840,5 +926,15 @@ function escapeHtml(str: string): string {
|
|||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\u2014/g, "—") // em dash
|
||||||
|
.replace(/\u2013/g, "–") // en dash
|
||||||
|
.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# sharp needs these native libs for image optimization
|
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
@@ -23,7 +23,6 @@ COPY --from=builder /app/public ./public
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
# sharp is bundled in standalone output when installed
|
|
||||||
ENV NEXT_SHARP_PATH=/app/node_modules/sharp
|
ENV NEXT_SHARP_PATH=/app/node_modules/sharp
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
51
pledge-now-pay-later/deploy.sh
Normal file
51
pledge-now-pay-later/deploy.sh
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fast deploy — BuildKit cache + persistent build dir
|
||||||
|
# Source-only changes: ~40-60s | With package changes: ~90s
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVER=root@159.195.60.33
|
||||||
|
CONTAINER=qc-server-new
|
||||||
|
START=$(date +%s)
|
||||||
|
|
||||||
|
step() { printf "\n\033[1;34m>>>\033[0m [%s] %s\n" "$1" "$2"; }
|
||||||
|
|
||||||
|
# ── Pack source (exclude heavy/unchanged stuff) ──
|
||||||
|
step "1/3" "Packing source..."
|
||||||
|
tar czf /tmp/pnpl.tar.gz \
|
||||||
|
--exclude=node_modules \
|
||||||
|
--exclude=.next \
|
||||||
|
--exclude=.git \
|
||||||
|
--exclude=brand/ \
|
||||||
|
--exclude=shots/ \
|
||||||
|
.
|
||||||
|
SIZE=$(wc -c < /tmp/pnpl.tar.gz | awk '{printf "%.1fM", $1/1048576}')
|
||||||
|
echo " ${SIZE} tarball"
|
||||||
|
|
||||||
|
# ── Upload ──
|
||||||
|
step "2/3" "Uploading..."
|
||||||
|
scp -o ConnectTimeout=20 -q /tmp/pnpl.tar.gz ${SERVER}:/tmp/pnpl.tar.gz
|
||||||
|
|
||||||
|
# ── Build + deploy with full cache ──
|
||||||
|
step "3/3" "Building + deploying..."
|
||||||
|
ssh -o ConnectTimeout=20 ${SERVER} "
|
||||||
|
incus file push /tmp/pnpl.tar.gz ${CONTAINER}/tmp/pnpl.tar.gz
|
||||||
|
incus exec ${CONTAINER} -- bash -c '
|
||||||
|
cd /opt/pnpl
|
||||||
|
tar xzf /tmp/pnpl.tar.gz
|
||||||
|
rm /tmp/pnpl.tar.gz
|
||||||
|
|
||||||
|
# BuildKit build with npm + Next.js cache mounts
|
||||||
|
docker buildx build -t pnpl-app:latest --load . 2>&1 | tail -8
|
||||||
|
|
||||||
|
# Rolling restart
|
||||||
|
docker service update --force --image pnpl-app:latest pnpl_app 2>&1 | tail -2
|
||||||
|
|
||||||
|
# Clean dangling only (preserve build cache!)
|
||||||
|
docker image prune -f 2>&1 | tail -1
|
||||||
|
'
|
||||||
|
rm -f /tmp/pnpl.tar.gz
|
||||||
|
"
|
||||||
|
|
||||||
|
ELAPSED=$(( $(date +%s) - START ))
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployed in ${ELAPSED}s ==="
|
||||||
@@ -49,8 +49,8 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{/* ━━ HERO (dark) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
{/* ━━ HERO (dark) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||||
<section className="bg-gray-950 overflow-hidden">
|
<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="max-w-7xl 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 md:items-stretch">
|
<div className="grid md:grid-cols-12 gap-10 md:gap-12 items-start md:items-stretch">
|
||||||
|
|
||||||
{/* ── Text column ── */}
|
{/* ── Text column ── */}
|
||||||
<div className="md:col-span-7 pt-2 md:pt-6 stagger-children">
|
<div className="md:col-span-7 pt-2 md:pt-6 stagger-children">
|
||||||
@@ -63,7 +63,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{/* Headline — Display scale */}
|
{/* Headline — Display scale */}
|
||||||
<h1 className="text-[2.75rem] leading-[0.95] sm:text-6xl md:text-[4.25rem] lg:text-7xl font-black text-white tracking-tighter">
|
<h1 className="text-[2.75rem] leading-[0.95] sm:text-6xl md:text-[4.25rem] lg:text-7xl font-black text-white tracking-tighter">
|
||||||
Turn “I'll donate” into money in the bank.
|
Turn “I'll donate”<br className="hidden lg:block" /> into money<br className="hidden lg:block" /> in the bank.
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Sub */}
|
{/* Sub */}
|
||||||
@@ -117,7 +117,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{/* Stat strip — gap-px pattern (signature pattern 2) */}
|
{/* Stat strip — gap-px pattern (signature pattern 2) */}
|
||||||
<div className="border-t border-gray-800/50">
|
<div className="border-t border-gray-800/50">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-800/50">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-800/50">
|
||||||
{HERO_STATS.map((s) => (
|
{HERO_STATS.map((s) => (
|
||||||
<div key={s.stat} className="bg-gray-950 py-6 px-6">
|
<div key={s.stat} className="bg-gray-950 py-6 px-6">
|
||||||
|
|||||||
BIN
screenshots/cr-cloudflare.png
Normal file
BIN
screenshots/cr-cloudflare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Reference in New Issue
Block a user