design(preview): match toggle button sizes to device toggles

This commit is contained in:
2026-06-12 15:00:40 -07:00
parent 6687b79bfd
commit 960232e525
8 changed files with 771 additions and 2127 deletions

View File

@@ -3,8 +3,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
Loader2, AlertCircle, ChevronDown, ChevronRight, Loader2,
Box, Container, CircleDot, AlertCircle,
ChevronDown,
ChevronRight,
Box,
Container,
CircleDot,
} from "lucide-react"; } from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree"; import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer"; import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
@@ -23,10 +28,7 @@ import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
* part of the product surface. * part of the product surface.
*/ */
type Selection = type Selection = { type: "file"; codebaseId: string; path: string } | null;
| { type: "file"; codebaseId: string; path: string }
| { type: "image"; uuid: string }
| null;
export default function CodeTab() { export default function CodeTab() {
const params = useParams(); const params = useParams();
@@ -34,32 +36,14 @@ export default function CodeTab() {
const { anatomy, loading, error } = useAnatomy(projectId); const { anatomy, loading, error } = useAnatomy(projectId);
const codebases = anatomy?.product.codebases ?? null; const codebases = anatomy?.product.codebases ?? null;
const images = anatomy?.product.images ?? null; const reason = anatomy?.codebasesReason;
const reason = anatomy?.codebasesReason;
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selection, setSelection] = useState<Selection>(null); const [selection, setSelection] = useState<Selection>(null);
useEffect(() => {
if (codebases && codebases[0]) {
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
}
}, [codebases]);
useEffect(() => { useEffect(() => {
setSelection(null); setSelection(null);
setExpanded(new Set());
}, [projectId]); }, [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; const showLoading = loading && !anatomy;
return ( return (
@@ -68,97 +52,78 @@ export default function CodeTab() {
{/* ── Left rail ── */} {/* ── Left rail ── */}
<section style={leftCol}> <section style={leftCol}>
{showLoading && ( {showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline> <Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)} )}
{error && !showLoading && ( {error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline> <Inline>
<AlertCircle size={13} /> {error}
</Inline>
)} )}
{anatomy && ( {anatomy && (
<> <>
{/* Codebases */} {/* Code Files */}
<RailGroup title="Codebases" count={codebases?.length ?? 0}> <RailGroup title="Code files" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && ( {codebases && codebases.length === 0 && (
<RailEmpty> <RailEmpty>
{reason === "no_repo" {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></>} 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> </RailEmpty>
)} )}
{codebases?.map(cb => { {codebases?.map((cb) => {
const isOpen = expanded.has(cb.id);
return ( return (
<article key={cb.id} style={codebaseTile}> <article key={cb.id} style={codebaseTile}>
<button <div style={tileHeader}>
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
>
<span style={chevronCell}> <span style={chevronCell}>
{isOpen <ChevronDown size={13} style={{ color: INK.mid }} />
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
</span> </span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} /> <Box
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left" }}> <div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div> <div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>} {cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div> </div>
</button> </div>
{isOpen && ( <div style={tileBody}>
<div style={tileBody}> <GiteaFileTree
<GiteaFileTree projectId={projectId}
projectId={projectId} rootPath={cb.path}
rootPath={cb.path} selectedPath={
selectedPath={ selection?.type === "file" &&
selection?.type === "file" && selection.codebaseId === cb.id selection.codebaseId === cb.id
? selection.path ? selection.path
: undefined : undefined
} }
onSelectFile={(p) => onSelectFile={(p) =>
setSelection({ type: "file", codebaseId: cb.id, path: p }) setSelection({
} type: "file",
/> codebaseId: cb.id,
</div> path: p,
)} })
}
/>
</div>
</article> </article>
); );
})} })}
</RailGroup> </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> </section>
@@ -170,12 +135,7 @@ export default function CodeTab() {
{selection?.type === "file" && ( {selection?.type === "file" && (
<GiteaFileViewer projectId={projectId} path={selection.path} /> <GiteaFileViewer projectId={projectId} path={selection.path} />
)} )}
{selection?.type === "image" && anatomy && ( {!selection && <Empty>Pick a codebase file on the left.</Empty>}
<ImageDetail uuid={selection.uuid} anatomy={anatomy} />
)}
{!selection && (
<Empty>Pick a codebase file or an image on the left.</Empty>
)}
</div> </div>
</aside> </aside>
</div> </div>
@@ -183,39 +143,19 @@ export default function CodeTab() {
); );
} }
// ──────────────────────────────────────────────────
// 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 // Bits
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
function RailGroup({ function RailGroup({
title, count, children, title,
}: { title: string; count: number; children: React.ReactNode }) { count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return ( return (
<div style={railGroup}> <div style={railGroup}>
<header style={railGroupHeader}> <header style={railGroupHeader}>
@@ -232,16 +172,28 @@ function RailEmpty({ children }: { children: React.ReactNode }) {
} }
function DetailRow({ function DetailRow({
label, value, dot, href, label,
}: { label: string; value: string; dot?: string; href?: string }) { value,
dot,
href,
}: {
label: string;
value: string;
dot?: string;
href?: string;
}) {
return ( return (
<div style={detailRow}> <div style={detailRow}>
<span style={detailLabel}>{label}</span> <span style={detailLabel}>{label}</span>
<span style={detailValue}> <span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />} {dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? ( {href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a> <a href={href} target="_blank" rel="noreferrer" style={detailLink}>
) : value} {value}
</a>
) : (
value
)}
</span> </span>
</div> </div>
); );
@@ -249,11 +201,19 @@ function DetailRow({
function Inline({ children }: { children: React.ReactNode }) { function Inline({ children }: { children: React.ReactNode }) {
return ( return (
<div style={{ <div
display: "flex", alignItems: "center", gap: 8, style={{
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid, display: "flex",
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8, alignItems: "center",
}}> gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children} {children}
</div> </div>
); );
@@ -261,10 +221,18 @@ function Inline({ children }: { children: React.ReactNode }) {
function Empty({ children }: { children: React.ReactNode }) { function Empty({ children }: { children: React.ReactNode }) {
return ( return (
<div style={{ <div
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", style={{
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center", flex: 1,
}}> display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children} {children}
</div> </div>
); );
@@ -275,7 +243,7 @@ function Empty({ children }: { children: React.ReactNode }) {
function paneHeading(s: Selection): string { function paneHeading(s: Selection): string {
if (!s) return "Preview"; if (!s) return "Preview";
if (s.type === "file") return `Preview · ${shortPath(s.path)}`; if (s.type === "file") return `Preview · ${shortPath(s.path)}`;
return "Image"; return "Preview";
} }
function shortPath(p: string) { function shortPath(p: string) {
const parts = p.split("/"); const parts = p.split("/");
@@ -286,7 +254,8 @@ function statusColor(status: string) {
const s = status.toLowerCase(); const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy"))
return "#c5392b";
return "#a09a90"; return "#a09a90";
} }
@@ -318,79 +287,156 @@ const grid: React.CSSProperties = {
alignItems: "stretch", alignItems: "stretch",
}; };
const leftCol: React.CSSProperties = { const leftCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", gap: 18, minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 18,
}; };
const rightCol: React.CSSProperties = { const rightCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", minWidth: 0,
display: "flex",
flexDirection: "column",
}; };
const heading: React.CSSProperties = { const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", fontSize: "0.72rem",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
margin: "0 0 14px",
};
const railGroup: React.CSSProperties = {
display: "flex",
flexDirection: "column",
}; };
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
const railGroupHeader: React.CSSProperties = { const railGroupHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 4px 8px", padding: "0 4px 8px",
}; };
const railGroupTitle: React.CSSProperties = { const railGroupTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", fontSize: "0.68rem",
textTransform: "uppercase", color: INK.muted, fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
}; };
const countPill: React.CSSProperties = { const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid, fontSize: "0.7rem",
padding: "1px 7px", borderRadius: 999, background: "#f3eee4", fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const railItems: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
}; };
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
const railEmpty: React.CSSProperties = { const railEmpty: React.CSSProperties = {
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted, padding: "10px 12px",
border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, fontSize: "0.74rem",
color: INK.muted,
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6, lineHeight: 1.6,
}; };
const nudge: React.CSSProperties = { const nudge: React.CSSProperties = {
display: "block", marginTop: 6, fontStyle: "normal", display: "block",
background: "#f3eee4", borderRadius: 4, padding: "3px 8px", marginTop: 6,
fontSize: "0.72rem", color: "#7a6a50", fontStyle: "normal",
background: "#f3eee4",
borderRadius: 4,
padding: "3px 8px",
fontSize: "0.72rem",
color: "#7a6a50",
}; };
const flatTile: React.CSSProperties = { const flatTile: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10, display: "flex",
width: "100%", padding: "12px 14px", alignItems: "center",
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, gap: 10,
cursor: "pointer", font: "inherit", color: "inherit", 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", transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
}; };
const codebaseTile: React.CSSProperties = { const codebaseTile: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden", background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 10,
overflow: "hidden",
}; };
const tileHeader: React.CSSProperties = { const tileHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, width: "100%", display: "flex",
padding: "12px 14px", background: "transparent", border: "none", alignItems: "center",
cursor: "pointer", font: "inherit", color: "inherit", gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
font: "inherit",
color: "inherit",
}; };
const tileLabel: React.CSSProperties = { const tileLabel: React.CSSProperties = {
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, fontSize: "0.85rem",
fontWeight: 600,
color: INK.ink,
marginBottom: 2,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem",
color: INK.mid,
lineHeight: 1.4,
}; };
const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 };
const tileBody: React.CSSProperties = { const tileBody: React.CSSProperties = {
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`, padding: "8px 10px 12px",
borderTop: `1px solid ${INK.borderSoft}`,
}; };
const chevronCell: React.CSSProperties = { const chevronCell: React.CSSProperties = {
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, width: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}; };
const panel: React.CSSProperties = { const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, background: INK.cardBg,
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}; };
const detailRow: React.CSSProperties = { const detailRow: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex",
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`, alignItems: "center",
justifyContent: "space-between",
padding: "12px 4px",
borderBottom: `1px solid ${INK.borderSoft}`,
}; };
const detailLabel: React.CSSProperties = { const detailLabel: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", fontSize: "0.72rem",
textTransform: "uppercase", color: INK.muted, fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: INK.muted,
}; };
const detailValue: React.CSSProperties = { const detailValue: React.CSSProperties = {
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center", fontSize: "0.85rem",
color: INK.ink,
display: "inline-flex",
alignItems: "center",
}; };
const detailLink: React.CSSProperties = { const detailLink: React.CSSProperties = {
color: INK.ink, textDecoration: "underline", color: INK.ink,
textDecoration: "underline",
}; };

View File

@@ -1,233 +1,6 @@
"use client"; import { redirect } from "next/navigation";
import { useState } from "react"; export default async function SettingsPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
import { useParams, useRouter } from "next/navigation"; const { workspace, projectId } = await params;
import { Settings, Trash2, AlertTriangle, Loader2, ArrowLeft } from "lucide-react"; redirect(`/${workspace}/project/${projectId}/settings/app`);
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,
};

View File

@@ -18,8 +18,12 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Users, Users,
HardDrive,
Blocks,
} from "lucide-react"; } from "lucide-react";
import { useAnatomy } from "@/components/project/use-anatomy";
export function DashboardSidebar({ export function DashboardSidebar({
workspace, workspace,
projectId, projectId,
@@ -39,23 +43,40 @@ export function DashboardSidebar({
Record<string, boolean> Record<string, boolean>
>({ >({
settings: true, settings: true,
data: true,
}); });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const { anatomy } = useAnatomy(projectId);
const databases = anatomy?.infrastructure?.databases ?? [];
if (isPreview) { if (isPreview) {
return <>{children}</>; return <>{children}</>;
} }
const toggleSection = (section: string) => { const handleSectionClick = (segment: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); if (!expandedSections[segment]) {
setExpandedSections((prev) => ({ ...prev, [segment]: true }));
}
}; };
const menuItems = [ const menuItems = [
{ segment: "overview", label: "Overview", Icon: LayoutGrid }, { segment: "overview", label: "Overview", Icon: LayoutGrid },
{ segment: "plan", label: "Plan & Specs", Icon: ClipboardList }, { segment: "plan", label: "Plan & Specs", Icon: ClipboardList },
{ segment: "code", label: "Code", Icon: Code2 }, { segment: "code", label: "Code", Icon: Code2 },
{ segment: "data", label: "Data", Icon: Database }, {
segment: "data",
label: "Data",
Icon: Database,
hasChildren: true,
children: databases.map((db) => ({
segment: `data/tables?db=${db.uuid}`,
label: db.name,
})),
},
{ segment: "storage", label: "Storage", Icon: HardDrive },
{ segment: "services", label: "Services", Icon: Blocks },
{ segment: "users", label: "Auth / Users", Icon: Users }, { segment: "users", label: "Auth / Users", Icon: Users },
{ segment: "integrations", label: "Integrations", Icon: Plug }, { segment: "integrations", label: "Integrations", Icon: Plug },
{ segment: "security", label: "Security", Icon: ShieldCheck }, { segment: "security", label: "Security", Icon: ShieldCheck },
@@ -67,6 +88,17 @@ export function DashboardSidebar({
Icon: BarChart2, Icon: BarChart2,
badge: "Soon", badge: "Soon",
}, },
{
segment: "marketing",
label: "Marketing",
Icon: BarChart2,
badge: "New",
hasChildren: true,
children: [
{ segment: "marketing/seo", label: "SEO & GEO" },
{ segment: "marketing/social", label: "Social content" },
],
},
{ {
segment: "settings", segment: "settings",
label: "Settings", label: "Settings",
@@ -170,9 +202,10 @@ export function DashboardSidebar({
}} }}
onClick={() => { onClick={() => {
if (item.hasChildren) { if (item.hasChildren) {
toggleSection(item.segment); setExpandedSections((prev) => ({
} else { ...prev,
// Navigate via link logic would happen here, we'll wrap the icon/label in a link [item.segment]: !prev[item.segment],
}));
} }
}} }}
> >
@@ -245,28 +278,41 @@ export function DashboardSidebar({
}} }}
> >
{item.children.map((child) => { {item.children.map((child) => {
const isChildActive = const href = child.segment.includes("?")
pathname === `${projectBase}/${child.segment}`; ? `${projectBase}/${child.segment.split("?")[0]}?${child.segment.split("?")[1]}`
: `${projectBase}/${child.segment}`;
let isChildActive = false;
if (child.segment.includes("?")) {
const [basePath, searchStr] = child.segment.split("?");
isChildActive =
pathname === `${projectBase}/${basePath}` &&
(typeof window !== "undefined"
? window.location.search.includes(searchStr)
: false);
} else {
isChildActive =
pathname === `${projectBase}/${child.segment}`;
}
return ( return (
<Link <Link
key={child.segment} key={child.segment}
href={`${projectBase}/${child.segment}`} href={href}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
padding: "6px 10px 6px 36px", padding: "6px 10px 6px 14px",
borderRadius: 8, marginLeft: "18px",
borderRadius: "0 8px 8px 0",
fontSize: "0.8rem", fontSize: "0.8rem",
fontWeight: 500, fontWeight: 500,
textDecoration: "none", textDecoration: "none",
color: isChildActive ? "#18181b" : "#52525b", color: isChildActive ? "#18181b" : "#52525b",
background: isChildActive background: "transparent",
? "#f4f4f5"
: "transparent",
transition: "all 0.1s ease", transition: "all 0.1s ease",
border: isChildActive borderLeft: isChildActive
? "1px solid #e4e4e7" ? "2px solid #18181b"
: "1px solid transparent", : "2px solid transparent",
}} }}
> >
{child.label} {child.label}

View File

@@ -37,6 +37,9 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) {
fontSize: "0.75rem", fontSize: "0.75rem",
fontWeight: 500, fontWeight: 500,
borderRadius: 6, borderRadius: 6,
display: "flex",
alignItems: "center",
height: 24, // Explicitly match device toggles height
textDecoration: "none", textDecoration: "none",
background: isPreviewActive ? "#ffffff" : "transparent", background: isPreviewActive ? "#ffffff" : "transparent",
color: isPreviewActive ? "#18181b" : "#71717a", color: isPreviewActive ? "#18181b" : "#71717a",
@@ -53,6 +56,9 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) {
fontSize: "0.75rem", fontSize: "0.75rem",
fontWeight: 500, fontWeight: 500,
borderRadius: 6, borderRadius: 6,
display: "flex",
alignItems: "center",
height: 24, // Explicitly match device toggles height
textDecoration: "none", textDecoration: "none",
background: !isPreviewActive ? "#ffffff" : "transparent", background: !isPreviewActive ? "#ffffff" : "transparent",
color: !isPreviewActive ? "#18181b" : "#71717a", color: !isPreviewActive ? "#18181b" : "#71717a",
@@ -401,12 +407,11 @@ const bar: React.CSSProperties = {
alignItems: "center", alignItems: "center",
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
height: "56px", // Explicitly set height height: "100%", // Inherit height from parent
padding: "0 16px", padding: "0 16px",
gap: 12, gap: 12,
boxSizing: "border-box", boxSizing: "border-box",
background: "#fafafa", background: "#faf8f5",
borderBottom: "1px solid #e4e4e7",
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
}; };

View File

@@ -1027,16 +1027,11 @@ export function ChatPanel({
); );
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState<string | null>(null); const [mcpToken, setMcpToken] = useState<string | null>(null);
const [isChatMinimized, setIsChatMinimized] = useState<boolean>(() => { const [isChatMinimized, setIsChatMinimized] = useState<boolean>(false);
if (typeof window === "undefined") return false;
return !window.location.pathname.includes("/preview");
});
// Auto-minimize when navigating to dashboard, auto-open when navigating to preview // Auto-minimize when navigating to dashboard, auto-open when navigating to preview
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { setIsChatMinimized(!pathname.includes("/preview"));
setIsChatMinimized(!window.location.pathname.includes("/preview"));
}
}, [pathname]); }, [pathname]);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -2101,12 +2096,9 @@ export function ChatPanel({
}} }}
onInput={(e) => { onInput={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
const newlines = (el.value.match(/\n/g) || []).length; // Only resize if height actually changed
if ((el as any).lastNewlines !== newlines) { el.style.height = "auto";
(el as any).lastNewlines = newlines; el.style.height = Math.min(el.scrollHeight, 240) + "px";
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 240) + "px";
}
}} }}
/> />
<div <div
@@ -2332,7 +2324,7 @@ export function ChatPanel({
alignItems: "stretch", alignItems: "stretch",
flexShrink: 0, flexShrink: 0,
height: 48, height: 48,
borderBottom: "1px solid #e8e4dc", borderBottom: "1px solid #e4e4e7",
background: "#faf8f5", background: "#faf8f5",
boxSizing: "border-box", boxSizing: "border-box",
}} }}
@@ -2347,7 +2339,7 @@ export function ChatPanel({
padding: "0 12px", padding: "0 12px",
gap: 6, gap: 6,
boxSizing: "border-box", boxSizing: "border-box",
borderRight: "1px solid #e8e4dc", borderRight: "1px solid #e4e4e7",
}} }}
> >
<div <div

View File

@@ -158,7 +158,10 @@ function renderDevCompose(projectSlug: string, projectId: string): string {
// process is actually listening on the port — Traefik does the // process is actually listening on the port — Traefik does the
// health check. // health check.
const token = projectPreviewToken(projectId); const token = projectPreviewToken(projectId);
const traefikLabels: string[] = ['"traefik.enable=true"']; const traefikLabels: string[] = [
'"traefik.enable=true"',
'"traefik.docker.network=coolify"',
];
for (let i = 0; i < PREVIEW_PORT_COUNT; i++) { for (let i = 0; i < PREVIEW_PORT_COUNT; i++) {
const port = PREVIEW_BASE_PORT + i; const port = PREVIEW_BASE_PORT + i;
const router = `vibn-dev-${projectSlug}-${i}`; const router = `vibn-dev-${projectSlug}-${i}`;