chore: convert submodules to standard directories for true monorepo structure

This commit is contained in:
2026-05-13 14:54:23 -07:00
parent 4339da259c
commit abf9bf89c2
761 changed files with 133928 additions and 2 deletions

View File

@@ -0,0 +1,66 @@
import { Suspense } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Loader2, CreditCard, ArrowRight, ShieldCheck, Zap } from "lucide-react";
export default async function BillingPage(props: { params: Promise<{ projectId: string }> }) {
const { projectId } = await props.params;
return (
<div style={{ padding: "40px 48px", maxWidth: 1000, margin: "0 auto", fontFamily: "var(--font-inter), sans-serif" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontFamily: "var(--font-lora), serif", fontSize: "1.8rem", color: "#1a1a1a", marginBottom: 8 }}>
Payments & Billing
</h1>
<p style={{ color: "#6b6560", fontSize: "0.95rem" }}>
Connect your bank account to start charging customers for this project.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "24px" }}>
{/* Onboarding Card */}
<Card style={{ border: "1px solid #6366f1", boxShadow: "0 4px 14px rgba(99, 102, 241, 0.08)" }}>
<CardHeader>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 8 }}>
<div style={{ background: "#e0e7ff", padding: 8, borderRadius: 8, color: "#4f46e5" }}>
<CreditCard style={{ width: 24, height: 24 }} />
</div>
<div>
<CardTitle style={{ fontSize: "1.2rem" }}>Accept Payments with Stripe</CardTitle>
<CardDescription>Setup takes 3 minutes. Vibn handles the code.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div style={{ background: "#f8fafc", padding: 20, borderRadius: 8, marginBottom: 24 }}>
<h4 style={{ fontWeight: 600, fontSize: "0.9rem", color: "#111827", marginBottom: 12 }}>What you get immediately:</h4>
<ul style={{ display: "flex", flexDirection: "column", gap: 12, margin: 0, padding: 0, listStyle: "none" }}>
<li style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: "0.85rem", color: "#4b5563" }}>
<Zap style={{ width: 16, height: 16, color: "#eab308", flexShrink: 0 }} />
<span><strong>AI Auto-Wiring:</strong> The Vibn AI will automatically inject your secure Stripe keys into your live Coolify application.</span>
</li>
<li style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: "0.85rem", color: "#4b5563" }}>
<ShieldCheck style={{ width: 16, height: 16, color: "#22c55e", flexShrink: 0 }} />
<span><strong>Instant Compliance:</strong> Securely accept Apple Pay, Google Pay, and credit cards with PCI compliance handled automatically.</span>
</li>
</ul>
</div>
<p style={{ fontSize: "0.85rem", color: "#6b7280", lineHeight: 1.5 }}>
By connecting, you agree to Stripe's Services Agreement. Vibn takes a small 1% platform fee on successful transactions to keep the AI platform running.
</p>
</CardContent>
<CardFooter style={{ background: "#f9fafb", borderTop: "1px solid #f3f4f6", padding: "16px 24px" }}>
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
style={{ padding: "10px 20px", borderRadius: 6, fontSize: "0.9rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 8 }}
>
Connect with Stripe <ArrowRight style={{ width: 16, height: 16 }} />
</button>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
/**
* Design systems tab — UI kit / token documentation (reference layouts).
* Distinct from /design in (workspace), which is the scaffold & theme studio.
*/
import { DesignSystemExplorer } from "@/components/project/design-system-explorer";
export default function DesignSystemPage() {
return <DesignSystemExplorer />;
}

View File

@@ -0,0 +1,462 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2, AlertCircle, ExternalLink, Globe, RefreshCw,
CircleDot, ChevronDown, ChevronRight, Copy, Check,
Terminal, Server,
} from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Hosting tab — user-facing: "Is my thing live? How do I reach it?"
*
* One endpoint = one card. Each card shows:
* - Live URL (open in new tab)
* - Status dot + plain-language status
* - Redeploy button
* - Domain(s) list
* - Last build (time + status)
* - Expandable recent logs
*
* No master-detail split — with 1-3 services the overhead isn't worth it.
* Previews (dev server URLs) shown below in a secondary section.
*/
// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────
type LiveItem = Anatomy["hosting"]["live"][number];
type Preview = Anatomy["hosting"]["previews"][number];
// ──────────────────────────────────────────────────
// Main component
// ──────────────────────────────────────────────────
export default function HostingTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 });
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
{showLoading && (
<div style={centeredMsg}>
<Loader2 size={16} className="animate-spin" style={{ color: INK.muted }} />
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>Loading</span>
</div>
)}
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
{anatomy && (
<>
{/* ── Live endpoints ── */}
<section>
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
{anatomy.hosting.live.length === 0 ? (
<EmptySection
icon={<Server size={20} style={{ color: INK.muted }} />}
title="Nothing deployed yet"
hint="Ask the AI to deploy your app and it will appear here."
promptSuggestion="Deploy my app to production"
/>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{anatomy.hosting.live.map(item => (
<LiveCard key={item.uuid} item={item} projectId={projectId} />
))}
</div>
)}
</section>
{/* ── Previews ── */}
{anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}>
<SectionHeader title="Dev Previews" count={anatomy.hosting.previews.length} />
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{anatomy.hosting.previews.map(p => (
<PreviewRow key={p.id} preview={p} />
))}
</div>
</section>
)}
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Live card
// ──────────────────────────────────────────────────
function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const [deploying, setDeploying] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const redeploy = async () => {
if (deploying) return;
setDeploying(true);
try {
await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.deploy",
params: { uuid: item.uuid, projectId },
}),
});
} finally {
setTimeout(() => setDeploying(false), 3000);
}
};
const openLogs = async () => {
if (!logsOpen) {
setLogsOpen(true);
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid: item.uuid, lines: 60 },
}),
});
const d = await r.json();
setLogs(typeof d.result === "string" ? d.result : JSON.stringify(d.result ?? d.error, null, 2));
} catch {
setLogs("Failed to load logs.");
} finally {
setLogsLoading(false);
}
} else {
setLogsOpen(false);
}
};
const copyUrl = () => {
if (!primaryUrl) return;
navigator.clipboard.writeText(primaryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={card}>
{/* ── Card header ── */}
<div style={cardHeader}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: 1 }}>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>{item.source === "repo" ? "built" : "image"}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={redeploy}
disabled={deploying}
style={actionBtn}
title="Redeploy now"
>
{deploying
? <Loader2 size={13} className="animate-spin" />
: <RefreshCw size={13} />}
{deploying ? "Deploying…" : "Redeploy"}
</button>
</div>
</div>
{/* ── Status line ── */}
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>{statusLabel}</span>
{item.lastBuild && (
<span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status} {formatRelative(item.lastBuild.finishedAt)}
</span>
)}
</div>
{/* ── Live URL ── */}
{primaryUrl ? (
<div style={urlRow}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
{primaryUrl}
</a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? <Check size={12} style={{ color: "#2e7d32" }} /> : <Copy size={12} />}
</button>
</div>
) : (
<div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span style={{ color: INK.muted, fontSize: "0.82rem", fontStyle: "italic" }}>
No domain attached ask the AI to add one.
</span>
</div>
)}
{/* ── Extra domains ── */}
{item.domains.length > 1 && (
<div style={{ paddingLeft: 23, display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
{item.domains.slice(1).map(d => (
<a
key={d}
href={`https://${d}`}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
>
{d} <ExternalLink size={10} style={{ display: "inline", verticalAlign: "middle" }} />
</a>
))}
</div>
)}
{/* ── Logs toggle ── */}
<div style={{ marginTop: 14, borderTop: `1px solid ${INK.borderSoft}`, paddingTop: 10 }}>
<button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{logsOpen && (
<div style={logsBox}>
{logsLoading
? <span style={{ color: INK.muted, fontSize: "0.8rem" }}>Loading</span>
: <pre style={logsPre}>{logs || "(no logs)"}</pre>}
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Preview row
// ──────────────────────────────────────────────────
function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running";
return (
<div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot size={10} style={{ color: running ? "#2e7d32" : INK.muted, flexShrink: 0 }} />
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>{preview.name}</span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>port {preview.port}</span>
{preview.url && running && (
<a href={preview.url} target="_blank" rel="noreferrer" style={urlLink}>
{preview.url} <ExternalLink size={10} style={{ display: "inline", verticalAlign: "middle" }} />
</a>
)}
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: INK.muted }}>
Started {formatRelative(preview.startedAt)}
</span>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
type Phase = "up" | "deploying" | "down" | "unknown";
function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase();
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (/^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(s)) return "deploying";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
return "unknown";
}
function phaseDisplay(phase: Phase, item: LiveItem): { color: string; label: string } {
if (item.inFlightBuild) return { color: AMBER, label: `Deploying (${item.inFlightBuild.status ?? "in progress"})` };
switch (phase) {
case "up": return { color: GREEN, label: "Live" };
case "deploying": return { color: AMBER, label: "Starting…" };
case "down": return { color: DANGER, label: "Down" };
default: return { color: INK.muted, label: "Unknown" };
}
}
function formatRelative(iso: string | undefined) {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// ──────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({ icon, title, hint, promptSuggestion }: {
icon: React.ReactNode; title: string; hint: string; promptSuggestion?: string;
}) {
return (
<div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div>
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.82rem", color: INK.mid, marginBottom: promptSuggestion ? 14 : 0 }}>{hint}</div>
{promptSuggestion && (
<div style={promptChip}>
<span style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}>Try asking:</span>
<span style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}>"{promptSuggestion}"</span>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const GREEN = "#2e7d32";
const AMBER = "#d4a04a";
const DANGER = "#c5392b";
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 860,
};
const centeredMsg: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10, padding: "24px 0",
};
const sectionHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, marginBottom: 14,
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
};
const card: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: "18px 20px",
};
const cardHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 12, marginBottom: 6,
};
const cardTitle: React.CSSProperties = {
fontSize: "0.95rem", fontWeight: 700, color: INK.ink,
};
const statusLine: React.CSSProperties = {
fontSize: "0.8rem", color: INK.mid, marginBottom: 12,
display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap",
};
const urlRow: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8,
background: "#f8f5f0", borderRadius: 6, padding: "8px 12px",
marginBottom: 2,
};
const urlLink: React.CSSProperties = {
fontSize: "0.85rem", color: INK.ink, textDecoration: "none",
flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis",
whiteSpace: "nowrap", display: "inline-flex", alignItems: "center", gap: 4,
};
const actionBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "6px 12px", border: `1px solid ${INK.border}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.78rem", fontWeight: 600, color: INK.mid,
transition: "background 0.1s, border-color 0.1s",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: 26, height: 26, border: "none", background: "transparent",
cursor: "pointer", color: INK.muted, borderRadius: 4,
flexShrink: 0,
};
const logsToggleBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: "0.75rem", fontWeight: 600, color: INK.mid,
background: "none", border: "none", cursor: "pointer",
font: "inherit", padding: 0,
};
const logsBox: React.CSSProperties = {
marginTop: 10, background: "#1a1a1a", borderRadius: 6,
padding: "12px 14px", maxHeight: 320, overflowY: "auto",
};
const logsPre: React.CSSProperties = {
margin: 0, fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem", color: "#d4d0c8", lineHeight: 1.6,
whiteSpace: "pre-wrap", wordBreak: "break-all",
};
const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.border}`, borderRadius: 10,
padding: "36px 28px", textAlign: "center",
display: "flex", flexDirection: "column", alignItems: "center",
};
const promptChip: React.CSSProperties = {
display: "inline-flex", alignItems: "center",
background: "#f3eee4", borderRadius: 6,
padding: "6px 12px", fontSize: "0.8rem",
};
function sourcePill(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
fontSize: "0.62rem", fontWeight: 700, letterSpacing: "0.08em",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px", borderRadius: 4, flexShrink: 0,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
/**
* Project shell — unified top bar (chat controls | section icons) and a
* split row below (conversation | artifact). No skinny workspace sidebar.
*/
import { ReactNode } from "react";
import { Toaster } from "sonner";
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
import { ChatPanel } from "@/components/vibn-chat/chat-panel";
export default async function ProjectShell({
children,
params,
}: {
children: ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace } = await params;
return (
<>
<div style={pageWrap}>
<ChatPanel structural artifactSlot={children} />
</div>
<ProjectAssociationPrompt workspace={workspace} />
<Toaster position="top-center" />
</>
);
}
const pageWrap: React.CSSProperties = {
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
height: "100vh",
background: "#faf8f5",
overflow: "hidden",
};

View File

@@ -0,0 +1,351 @@
import { BigQuery } from '@google-cloud/bigquery';
import { Suspense } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, Users, Target, Search, Database } from "lucide-react";
async function getMarketData(projectId: string) {
let bqOptions: any = { projectId: process.env.GCP_PROJECT_ID || 'master-ai-484822' };
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64, 'base64').toString('utf8');
bqOptions.credentials = JSON.parse(saStr);
bqOptions.projectId = bqOptions.credentials.project_id;
} catch (e) {}
}
const bigquery = new BigQuery(bqOptions);
try {
const [leads] = await bigquery.query({
query: `SELECT * FROM \`master-ai-484822.vibn_market_data.market_leads\` WHERE project_id = @projectId OR project_id = 'SYSTEM_BACKFILL' LIMIT 50`,
params: { projectId }
});
const [aggregations] = await bigquery.query({
query: `SELECT * FROM \`master-ai-484822.vibn_market_data.market_aggregations\` ORDER BY last_updated DESC LIMIT 1`
});
const [competitors] = await bigquery.query({
query: `SELECT * FROM \`master-ai-484822.vibn_market_data.software_providers_seo\` ORDER BY last_updated DESC LIMIT 10`
});
return { leads, aggregations: aggregations[0], competitors };
} catch (err) {
console.error("BigQuery Error:", err);
return { leads: [], aggregations: null, competitors: [] };
}
}
export default async function MarketPage(props: { params: Promise<{ projectId: string }> }) {
const { projectId } = await props.params;
return (
<div style={{ padding: "40px 48px", maxWidth: 1200, margin: "0 auto", fontFamily: "var(--font-inter), sans-serif" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontFamily: "var(--font-lora), serif", fontSize: "1.8rem", color: "#1a1a1a", marginBottom: 8 }}>
Market Intelligence
</h1>
<p style={{ color: "#6b6560", fontSize: "0.95rem" }}>
Real-time TAM, verified leads, and competitor teardowns from the Vibn Data Co-op.
</p>
</div>
<Suspense fallback={<div className="flex justify-center p-12"><Loader2 className="animate-spin w-8 h-8 text-gray-400" /></div>}>
<MarketDataDisplay projectId={projectId} />
</Suspense>
</div>
);
}
async function MarketDataDisplay({ projectId }: { projectId: string }) {
const data = await getMarketData(projectId);
if (!data.aggregations && data.leads.length === 0) {
return (
<Card>
<CardContent className="py-16 text-center" style={{ paddingTop: '4rem', paddingBottom: '4rem', textAlign: 'center' }}>
<Database style={{ width: 48, height: 48, margin: '0 auto 16px', color: '#d0ccc4' }} />
<h3 style={{ fontSize: '1.125rem', fontWeight: 500, color: '#111827', marginBottom: '8px' }}>No Market Data Yet</h3>
<p style={{ color: '#6b7280', maxWidth: '28rem', margin: '0 auto' }}>
Ask the Vibn AI to run market research for your niche to populate this dashboard with leads, competitors, and SEO insights.
</p>
</CardContent>
</Card>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "32px" }}>
{/* Overview Cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", gap: "24px" }}>
<Card>
<CardHeader style={{ paddingBottom: '8px' }}>
<CardTitle style={{ fontSize: '0.875rem', fontWeight: 500, color: '#6b7280', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Users style={{ width: 16, height: 16 }} /> Total Addressable Market
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ fontSize: '1.875rem', fontWeight: 600, color: '#111827' }}>
{data.aggregations?.total_market_size?.toLocaleString() || "..."}
</div>
<p style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '4px' }}>Verified businesses in selected region</p>
</CardContent>
</Card>
<Card>
<CardHeader style={{ paddingBottom: '8px' }}>
<CardTitle style={{ fontSize: '0.875rem', fontWeight: 500, color: '#6b7280', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Target style={{ width: 16, height: 16 }} /> Qualified Leads Captured
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ fontSize: '1.875rem', fontWeight: 600, color: '#111827' }}>
{data.leads.length}
</div>
<p style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '4px' }}>Ready for cold outreach</p>
</CardContent>
</Card>
<Card>
<CardHeader style={{ paddingBottom: '8px' }}>
<CardTitle style={{ fontSize: '0.875rem', fontWeight: 500, color: '#6b7280', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Search style={{ width: 16, height: 16 }} /> Tech Debt Indicator
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ fontSize: '1.875rem', fontWeight: 600, color: '#111827' }}>
{data.aggregations ? Math.round((data.aggregations.websites_count / data.aggregations.total_market_size) * 100) : 0}%
</div>
<p style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '4px' }}>Of TAM have a website</p>
</CardContent>
</Card>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "32px" }}>
{/* Pain Points */}
{data.aggregations && (
<Card>
<CardHeader>
<CardTitle>Customer Pain Points</CardTitle>
<CardDescription>Extracted from Google Reviews</CardDescription>
</CardHeader>
<CardContent>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
{Object.entries(typeof data.aggregations.customer_pain_points === 'string' ? JSON.parse(data.aggregations.customer_pain_points) : data.aggregations.customer_pain_points || {})
.sort(([, a], [, b]) => (b as number) - (a as number))
.slice(0, 15)
.map(([topic, count]) => (
<span key={topic} style={{ padding: "4px 12px", background: "#f0ede8", color: "#6b6560", fontSize: "0.75rem", fontWeight: 500, borderRadius: "9999px" }}>
{topic} ({(count as number).toLocaleString()})
</span>
))}
</div>
</CardContent>
</Card>
)}
{/* Sub-niches */}
{data.aggregations && (
<Card>
<CardHeader>
<CardTitle>Market Sub-Niches</CardTitle>
<CardDescription>Breakdown of primary category</CardDescription>
</CardHeader>
<CardContent>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{Object.entries(typeof data.aggregations.sub_niches === 'string' ? JSON.parse(data.aggregations.sub_niches) : data.aggregations.sub_niches || {})
.sort(([, a], [, b]) => (b as number) - (a as number))
.slice(0, 6)
.map(([topic, count]) => (
<div key={topic} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.875rem" }}>
<span style={{ color: "#374151", textTransform: "capitalize" }}>{topic.replace(/_/g, ' ')}</span>
<span style={{ fontWeight: 500 }}>{(count as number).toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Competitors */}
{data.competitors.length > 0 && (
<Card>
<CardHeader>
<CardTitle>SaaS Competitors & Ad Spend</CardTitle>
<CardDescription>Top incumbents and their Google Ads budget</CardDescription>
</CardHeader>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", fontSize: "0.875rem", textAlign: "left" }}>
<thead style={{ fontSize: "0.75rem", color: "#6b7280", textTransform: "uppercase", background: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
<tr>
<th style={{ padding: "12px 24px" }}>Domain</th>
<th style={{ padding: "12px 24px" }}>Monthly Ad Spend</th>
<th style={{ padding: "12px 24px" }}>Organic Traffic</th>
<th style={{ padding: "12px 24px" }}>Top Paid Keywords</th>
</tr>
</thead>
<tbody>
{data.competitors.map((comp: any) => {
const paidKw = typeof comp.top_paid_keywords === 'string' ? JSON.parse(comp.top_paid_keywords) : comp.top_paid_keywords;
return (
<tr key={comp.domain} style={{ background: "#fff", borderBottom: "1px solid #e5e7eb" }}>
<td style={{ padding: "16px 24px", fontWeight: 500, color: "#111827" }}>{comp.domain}</td>
<td style={{ padding: "16px 24px", color: "#dc2626", fontWeight: 500 }}>
${Math.round(comp.ad_spend_usd).toLocaleString()}
</td>
<td style={{ padding: "16px 24px" }}>
{Math.round(comp.organic_traffic).toLocaleString()} /mo
</td>
<td style={{ padding: "16px 24px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
{(paidKw || []).slice(0, 3).map((kw: string) => (
<span key={kw} style={{ padding: "2px 8px", background: "#eff6ff", color: "#1d4ed8", fontSize: "0.625rem", borderRadius: "4px" }}>
{kw}
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
{/* Leads Table */}
{data.leads.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Verified Leads</CardTitle>
<CardDescription>First {data.leads.length} contacts matching your target market</CardDescription>
</CardHeader>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", fontSize: "0.875rem", textAlign: "left" }}>
<thead style={{ fontSize: "0.75rem", color: "#6b7280", textTransform: "uppercase", background: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
<tr>
<th style={{ padding: "12px 24px" }}>Business Name</th>
<th style={{ padding: "12px 24px" }}>Location</th>
<th style={{ padding: "12px 24px" }}>Rating</th>
<th style={{ padding: "12px 24px" }}>Contact</th>
</tr>
</thead>
<tbody>
{data.leads.map((lead: any) => {
const emails = typeof lead.emails === 'string' ? JSON.parse(lead.emails) : lead.emails;
return (
<tr key={lead.place_id} style={{ background: "#fff", borderBottom: "1px solid #e5e7eb" }}>
<td style={{ padding: "16px 24px", fontWeight: 500, color: "#111827" }}>
{lead.name}
{lead.website && (
<a href={lead.website.startsWith('http') ? lead.website : `https://${lead.website}`} target="_blank" rel="noreferrer" style={{ display: "block", color: "#2563eb", fontSize: "0.75rem", marginTop: "4px", textDecoration: "none" }}>
{lead.website.replace(/^https?:\/\//, '')}
</a>
)}
</td>
<td style={{ padding: "16px 24px" }}>
{lead.city}, {lead.region}
</td>
<td style={{ padding: "16px 24px" }}>
{lead.rating ? `${lead.rating} ⭐ (${lead.reviews_count})` : 'N/A'}
</td>
<td style={{ padding: "16px 24px" }}>
<div style={{ fontSize: "0.75rem", color: "#4b5563" }}>
{lead.phone && <div style={{ marginBottom: "4px" }}>{lead.phone}</div>}
{(emails || []).map((e: string) => (
<a key={e} href={`mailto:${e}`} style={{ display: "block", color: "#2563eb", textDecoration: "none" }}>
{e}
</a>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
{/* ───────────────────────────────────────────────────────────── */}
{/* GO-TO-MARKET (GTM) STRATEGY ENGINE */}
{/* ───────────────────────────────────────────────────────────── */}
<div style={{ marginTop: "48px", borderTop: "1px solid #e5e7eb", paddingTop: "32px" }}>
<div style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h2 style={{ fontFamily: "var(--font-lora), serif", fontSize: "1.5rem", color: "#111827", marginBottom: 4 }}>
Go-To-Market Strategy
</h2>
<p style={{ color: "#6b7280", fontSize: "0.875rem" }}>
Synthesize market data into an actionable marketing and positioning plan.
</p>
</div>
<button
style={{
background: "linear-gradient(to bottom right, #4f46e5, #312e81)",
color: "#fff",
padding: "8px 16px",
borderRadius: 6,
fontSize: "0.875rem",
fontWeight: 500,
boxShadow: "0 2px 4px rgba(79, 70, 229, 0.2)",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8
}}
onClick={() => alert("This will deduct 500 AI Credits and generate the GTM strategy.")}
>
<span style={{ fontSize: "1.1rem", lineHeight: 1 }}></span> Generate GTM Plan (500 Credits)
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px", opacity: 0.5, pointerEvents: "none" }}>
<Card>
<CardHeader>
<CardTitle>Brand Positioning</CardTitle>
<CardDescription>Value prop, target persona, and wedge strategy.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ background: "#f9fafb", padding: 24, borderRadius: 8, textAlign: "center", border: "1px dashed #d1d5db" }}>
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>Generate a plan to reveal the positioning strategy.</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>SEO & Content Engine</CardTitle>
<CardDescription>Keyword gaps and initial blog architecture.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ background: "#f9fafb", padding: 24, borderRadius: 8, textAlign: "center", border: "1px dashed #d1d5db" }}>
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>Generate a plan to reveal keyword targets.</p>
</div>
</CardContent>
</Card>
</div>
<div style={{ marginTop: "24px", opacity: 0.5, pointerEvents: "none" }}>
<Card>
<CardHeader>
<CardTitle style={{ display: "flex", alignItems: "center", gap: 8 }}>
Social Media Automation
<span style={{ fontSize: "0.65rem", background: "#f3f4f6", padding: "2px 6px", borderRadius: 4, fontWeight: 600, color: "#4b5563" }}>POWERED BY MISSINGLETTR</span>
</CardTitle>
<CardDescription>A 3-month automated drip campaign based on your positioning.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ background: "#f9fafb", padding: 48, borderRadius: 8, textAlign: "center", border: "1px dashed #d1d5db" }}>
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>Generate a plan to automatically orchestrate your social media strategy via Missinglettr.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
/**
* /[workspace]/project/[projectId]
*
* Bare project URL is a server-side redirect into the default tab
* (Product). The actual landing experience lives under
* `/[workspace]/project/[projectId]/product` with the shared tab
* shell rendered by `(home)/layout.tsx`.
*
* Why redirect rather than render: keeping every tab as its own URL
* means refresh / back / share always lands the user on the right
* surface, and Next.js can prefetch each tab independently.
*/
export default async function ProjectIndexPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/preview`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { useAnatomy } from "@/components/project/use-anatomy";
import { usePreviewBridge } from "@/components/project/preview-bridge-context";
const SAME_ORIGIN_SANDBOX =
"allow-scripts allow-forms allow-same-origin allow-popups allow-modals allow-downloads" as const;
function sandboxIframe(src: string, origin: string): boolean {
if (!src.startsWith("http://") && !src.startsWith("https://")) return true;
try {
return origin.length > 0 && new URL(src).origin === origin;
} catch {
return true;
}
}
export default function PreviewTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 0 });
const previews = anatomy?.hosting.previews ?? [];
const options = previews.filter((p) => p.url);
const [selectedUrl, setSelectedUrl] = useState<string | null>(null);
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const iframeDomRef = useRef<HTMLIFrameElement | null>(null);
const bridge = usePreviewBridge();
const origin = typeof window !== "undefined" ? window.location.origin : "";
// Auto-select first preview on load
useEffect(() => {
if (!selectedUrl && options.length > 0) {
setSelectedUrl(options[0].url);
}
}, [options, selectedUrl]);
useLayoutEffect(() => {
setIframeSrc(selectedUrl ?? null);
}, [selectedUrl]);
useEffect(() => {
if (!bridge || !iframeSrc || !iframeDomRef.current) return;
bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc);
}, [bridge, iframeSrc]);
return (
<div style={canvas}>
{options.length > 1 && (
<div style={toolbar}>
<select
value={selectedUrl ?? ""}
onChange={(e) => setSelectedUrl(e.target.value)}
style={select}
>
{options.map((p) => (
<option key={p.id} value={p.url}>
{p.name} :{p.port} {p.state}
{p.command ? ` (${p.command.slice(0, 60)})` : ""}
</option>
))}
</select>
</div>
)}
<div style={previewFrame}>
{loading && !iframeSrc ? (
<div style={loaderWrap}>
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
</div>
) : iframeSrc ? (
<iframe
key={iframeSrc}
src={iframeSrc}
title="Preview"
ref={(el) => {
iframeDomRef.current = el;
bridge?.registerPreviewIframe(el, iframeSrc);
}}
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={iframeStyle}
{...(sandboxIframe(iframeSrc, origin)
? { sandbox: SAME_ORIGIN_SANDBOX }
: {})}
/>
) : (
<div style={loaderWrap}>
<p style={emptyText}>No preview available</p>
</div>
)}
</div>
</div>
);
}
const canvas: React.CSSProperties = {
flex: 1,
minHeight: 0,
width: "100%",
display: "flex",
flexDirection: "column",
alignSelf: "stretch",
boxSizing: "border-box",
padding: "14px 16px 18px",
background: "linear-gradient(165deg, #faf8f5 0%, #f4f0ea 42%, #ebe7df 100%)",
};
const toolbar: React.CSSProperties = {
display: "flex",
gap: 8,
marginBottom: 10,
flexWrap: "wrap",
};
const select: React.CSSProperties = {
flex: 1,
maxWidth: 480,
padding: "6px 10px",
borderRadius: 8,
border: "1px solid rgba(26, 26, 26, 0.12)",
background: "rgba(255,255,255,0.85)",
fontSize: "0.8rem",
fontFamily: "inherit",
color: "#1a1a1a",
};
const previewFrame: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
borderRadius: 14,
overflow: "hidden",
background: "#fff",
border: "1px solid rgba(26, 26, 26, 0.07)",
boxShadow:
"0 1px 2px rgba(26, 26, 26, 0.04), 0 12px 40px rgba(26, 26, 26, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.85)",
};
const iframeStyle: React.CSSProperties = {
flex: 1,
width: "100%",
minHeight: 0,
border: "none",
background: "#fcfcfb",
display: "block",
};
const loaderWrap: React.CSSProperties = {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 200,
background: "#fcfcfb",
};
const emptyText: React.CSSProperties = {
fontSize: "0.85rem",
color: "#a09a90",
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
};

View File

@@ -0,0 +1,396 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2, AlertCircle, ChevronDown, ChevronRight,
Box, Container, CircleDot,
} from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Product tab — everything that makes up the thing being shipped.
*
* Left rail (top → bottom):
* 1. Codebases — Gitea repos, each tile expands inline into a file
* tree; clicking a file previews it on the right.
* 2. Images — Coolify services backed by an upstream Docker image
* (Twenty CRM, n8n…). Clicking shows image meta on the right.
*
* Dev containers do not appear here — they are the AI's workshop, not
* part of the product surface.
*/
type Selection =
| { type: "file"; codebaseId: string; path: string }
| { type: "image"; uuid: string }
| null;
export default function ProductTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
const codebases = anatomy?.product.codebases ?? null;
const images = anatomy?.product.images ?? null;
const reason = anatomy?.codebasesReason;
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selection, setSelection] = useState<Selection>(null);
useEffect(() => {
if (codebases && codebases[0]) {
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
}
}, [codebases]);
useEffect(() => {
setSelection(null);
setExpanded(new Set());
}, [projectId]);
const toggleCodebase = (id: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{anatomy && (
<>
{/* Codebases */}
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo"
? <>No codebase yet. <span style={nudge}>Try: &quot;Start building my app&quot;</span></>
: <>Repo is empty push a first commit. <span style={nudge}>Try: &quot;Scaffold a Next.js app&quot;</span></>}
</RailEmpty>
)}
{codebases?.map(cb => {
const isOpen = expanded.has(cb.id);
return (
<article key={cb.id} style={codebaseTile}>
<button
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
</span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div>
</button>
{isOpen && (
<div style={tileBody}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selection?.type === "file" && selection.codebaseId === cb.id
? selection.path
: undefined
}
onSelectFile={(p) =>
setSelection({ type: "file", codebaseId: cb.id, path: p })
}
/>
</div>
)}
</article>
);
})}
</RailGroup>
{/* Images */}
<RailGroup title="Images" count={images?.length ?? 0}>
{images && images.length === 0 && (
<RailEmpty>
Self-hosted tools (Twenty CRM, n8n, Plausible) you run appear here.
<span style={nudge}>Try: &quot;Install Twenty CRM for my project&quot;</span>
</RailEmpty>
)}
{images?.map(img => (
<button
key={img.uuid}
type="button"
onClick={() => setSelection({ type: "image", uuid: img.uuid })}
style={{
...flatTile,
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft,
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none",
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid}
>
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{img.name}</div>
<div style={tileHint}>
{img.image}{img.version ? `:${img.version}` : ""}
</div>
</div>
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />}
</button>
))}
</RailGroup>
</>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
{selection?.type === "file" && (
<GiteaFileViewer projectId={projectId} path={selection.path} />
)}
{selection?.type === "image" && anatomy && (
<ImageDetail uuid={selection.uuid} anatomy={anatomy} />
)}
{!selection && (
<Empty>Pick a codebase file or an image on the left.</Empty>
)}
</div>
</aside>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Image details (right pane)
// ──────────────────────────────────────────────────
function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
const img = anatomy.product.images.find(i => i.uuid === uuid);
if (!img) return <Empty>This image is no longer in the project.</Empty>;
const live = anatomy.hosting.live.find(l => l.uuid === uuid);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
<DetailRow label="Image" value={img.image} />
<DetailRow label="Version" value={img.version || "latest"} />
<DetailRow label="Type" value={img.serviceType ?? "—"} />
<DetailRow
label="Status"
value={img.status ?? "unknown"}
dot={statusColor(img.status ?? "")}
/>
{live?.fqdn && (
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} />
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title, count, children,
}: { title: string; count: number; children: React.ReactNode }) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function DetailRow({
label, value, dot, href,
}: { label: string; value: string; dot?: string; href?: string }) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a>
) : value}
</span>
</div>
);
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
}}>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
{children}
</div>
);
}
// ──────────────────────────────────────────────────
function paneHeading(s: Selection): string {
if (!s) return "Preview";
if (s.type === "file") return `Preview · ${shortPath(s.path)}`;
return "Image";
}
function shortPath(p: string) {
const parts = p.split("/");
if (parts.length <= 2) return p;
return ".../" + parts.slice(-2).join("/");
}
function statusColor(status: string) {
const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
return "#a09a90";
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
fontFamily: INK.fontSans,
color: INK.ink,
};
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
gap: 28,
maxWidth: 1400,
margin: "0 auto",
alignItems: "stretch",
};
const leftCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
};
const rightCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column",
};
const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
};
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
const railGroupHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0 4px 8px",
};
const railGroupTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
};
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
const railEmpty: React.CSSProperties = {
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
lineHeight: 1.6,
};
const nudge: React.CSSProperties = {
display: "block", marginTop: 6, fontStyle: "normal",
background: "#f3eee4", borderRadius: 4, padding: "3px 8px",
fontSize: "0.72rem", color: "#7a6a50",
};
const flatTile: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
width: "100%", padding: "12px 14px",
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10,
cursor: "pointer", font: "inherit", color: "inherit",
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
};
const codebaseTile: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden",
};
const tileHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, width: "100%",
padding: "12px 14px", background: "transparent", border: "none",
cursor: "pointer", font: "inherit", color: "inherit",
};
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
};
const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 };
const tileBody: React.CSSProperties = {
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
};
const chevronCell: React.CSSProperties = {
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
};
const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
};
const detailRow: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
};
const detailLabel: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
textTransform: "uppercase", color: INK.muted,
};
const detailValue: React.CSSProperties = {
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
};
const detailLink: React.CSSProperties = {
color: INK.ink, textDecoration: "underline",
};

View File

@@ -0,0 +1,233 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Settings, Trash2, AlertTriangle, Loader2, ArrowLeft } from "lucide-react";
import { WorkspaceKeysPanel } from "@/components/workspace/WorkspaceKeysPanel";
import Link from "next/link";
/**
* Project settings page.
* Accessible via the gear icon in the project header.
*
* Sections:
* - General (name, description — future)
* - Danger zone: delete project
*/
export default function ProjectSettingsPage() {
const params = useParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [deletePhase, setDeletePhase] = useState<"idle" | "confirm" | "deleting" | "done">("idle");
const [confirmInput, setConfirmInput] = useState("");
const [deleteError, setDeleteError] = useState<string | null>(null);
const projectBackUrl = `/${workspace}/project/${projectId}/plan`;
const handleDelete = async () => {
if (deletePhase === "idle") {
setDeletePhase("confirm");
return;
}
if (deletePhase !== "confirm") return;
if (confirmInput.toLowerCase() !== "delete") return;
setDeletePhase("deleting");
setDeleteError(null);
try {
const r = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Delete failed");
setDeletePhase("done");
setTimeout(() => router.push(`/${workspace}/projects`), 1500);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : String(e));
setDeletePhase("confirm");
}
};
return (
<div style={pageWrap}>
{/* Back link */}
<Link href={projectBackUrl} style={backLink}>
<ArrowLeft size={14} /> Back to project
</Link>
<h1 style={pageTitle}>
<Settings size={18} /> Project settings
</h1>
<div style={{ marginBottom: 40 }}><WorkspaceKeysPanel workspaceSlug={workspace} /></div>
{/* ── Danger zone ── */}
<section style={dangerSection}>
<h2 style={sectionTitle}>
<AlertTriangle size={15} style={{ color: DANGER }} />
Danger zone
</h2>
<div style={dangerCard}>
<div style={dangerCardBody}>
<div>
<div style={dangerItemTitle}>Delete this project</div>
<div style={dangerItemDesc}>
Removes all project data from Vibn. Coolify services and databases
are <strong>not</strong> automatically stopped use the chat to clean those
up first, or remove them from Coolify directly.
</div>
</div>
{deletePhase === "idle" && (
<button onClick={handleDelete} style={dangerBtn}>
<Trash2 size={13} /> Delete project
</button>
)}
{deletePhase === "confirm" && (
<div style={confirmBox}>
<div style={{ fontSize: "0.82rem", color: DANGER, fontWeight: 600, marginBottom: 8 }}>
Type <strong>delete</strong> to confirm
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
autoFocus
value={confirmInput}
onChange={e => setConfirmInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && confirmInput.toLowerCase() === "delete" && handleDelete()}
placeholder="delete"
style={confirmInput_}
/>
<button
onClick={handleDelete}
disabled={confirmInput.toLowerCase() !== "delete"}
style={{
...dangerBtn,
opacity: confirmInput.toLowerCase() !== "delete" ? 0.4 : 1,
}}
>
<Trash2 size={13} /> Confirm delete
</button>
<button
onClick={() => { setDeletePhase("idle"); setConfirmInput(""); setDeleteError(null); }}
style={cancelBtn}
>
Cancel
</button>
</div>
{deleteError && (
<div style={{ marginTop: 8, fontSize: "0.8rem", color: DANGER }}>{deleteError}</div>
)}
</div>
)}
{deletePhase === "deleting" && (
<button style={{ ...dangerBtn, opacity: 0.6 }} disabled>
<Loader2 size={13} className="animate-spin" /> Deleting
</button>
)}
{deletePhase === "done" && (
<div style={{ fontSize: "0.85rem", color: "#2e7d32", fontWeight: 600 }}>
Project deleted. Redirecting
</div>
)}
</div>
</div>
</section>
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const DANGER = "#c5392b";
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 720,
};
const backLink: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: "0.8rem", color: INK.mid, textDecoration: "none",
marginBottom: 24,
};
const pageTitle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
fontSize: "1.25rem", fontWeight: 700, color: INK.ink,
marginBottom: 36, marginTop: 0,
};
const dangerSection: React.CSSProperties = { marginTop: 32 };
const sectionTitle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8,
fontSize: "0.72rem", fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
marginBottom: 12,
};
const dangerCard: React.CSSProperties = {
border: `1px solid #f0cac5`,
borderRadius: 10,
background: "#fffaf9",
};
const dangerCardBody: React.CSSProperties = {
padding: "18px 20px",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 24,
flexWrap: "wrap",
};
const dangerItemTitle: React.CSSProperties = {
fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 4,
};
const dangerItemDesc: React.CSSProperties = {
fontSize: "0.8rem", color: INK.mid, lineHeight: 1.55, maxWidth: 380,
};
const dangerBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "7px 14px", border: `1px solid ${DANGER}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.8rem", fontWeight: 600, color: DANGER,
whiteSpace: "nowrap", flexShrink: 0,
};
const cancelBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center",
padding: "7px 12px", border: `1px solid ${INK.border}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.8rem", color: INK.mid,
whiteSpace: "nowrap",
};
const confirmBox: React.CSSProperties = { display: "flex", flexDirection: "column" };
const confirmInput_: React.CSSProperties = {
padding: "7px 10px",
border: `1px solid ${DANGER}`,
borderRadius: 6,
font: "inherit",
fontSize: "0.85rem",
outline: "none",
width: 100,
};