refactor: rework project page layout - sidebar as product OS, full-width content
- VIBNSidebar: when inside a project, lower section now shows 7 product layer sections (Apps, Layouts, Infrastructure, Growth, Monetize, Support, Analytics) instead of the projects list. Sections self-fetch data from /api/projects/[id] and /api/projects/[id]/apps. On non-project pages, reverts to the projects list as before. - ProjectShell: removed the project header strip (name/status/progress bar) and the persistent 230px right panel entirely. Tab bar now sits at the top of the content area with no header above it. Content is full-width. Each page manages its own internal layout. Made-with: Cursor
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode } from "react";
|
||||||
import { VIBNSidebar } from "./vibn-sidebar";
|
import { VIBNSidebar } from "./vibn-sidebar";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
@@ -23,14 +23,14 @@ interface ProjectShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TABS = [
|
const ALL_TABS = [
|
||||||
{ id: "overview", label: "Atlas", path: "overview" },
|
{ id: "overview", label: "Atlas", path: "overview" },
|
||||||
{ id: "prd", label: "PRD", path: "prd" },
|
{ id: "prd", label: "PRD", path: "prd" },
|
||||||
{ id: "design", label: "Design", path: "design" },
|
{ id: "design", label: "Design", path: "design" },
|
||||||
{ id: "build", label: "Build", path: "build" },
|
{ id: "build", label: "Build", path: "build" },
|
||||||
{ id: "deployment", label: "Launch", path: "deployment" },
|
{ id: "deployment", label: "Launch", path: "deployment" },
|
||||||
{ id: "grow", label: "Grow", path: "grow" },
|
{ id: "grow", label: "Grow", path: "grow" },
|
||||||
{ id: "insights", label: "Insights", path: "insights" },
|
{ id: "insights", label: "Insights", path: "insights" },
|
||||||
{ id: "settings", label: "Settings", path: "settings" },
|
{ id: "settings", label: "Settings", path: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getTabsForMode(
|
function getTabsForMode(
|
||||||
@@ -38,10 +38,8 @@ function getTabsForMode(
|
|||||||
) {
|
) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "code-import":
|
case "code-import":
|
||||||
// Hide PRD — this project already has code; goal is go-to-market surfaces
|
|
||||||
return ALL_TABS.filter(t => t.id !== "prd");
|
return ALL_TABS.filter(t => t.id !== "prd");
|
||||||
case "migration":
|
case "migration":
|
||||||
// Hide PRD, rename overview, hide Grow and Insights (less relevant)
|
|
||||||
return ALL_TABS
|
return ALL_TABS
|
||||||
.filter(t => !["prd", "grow", "insights"].includes(t.id))
|
.filter(t => !["prd", "grow", "insights"].includes(t.id))
|
||||||
.map(t => t.id === "overview" ? { ...t, label: "Migration Plan" } : t);
|
.map(t => t.id === "overview" ? { ...t, label: "Migration Plan" } : t);
|
||||||
@@ -50,323 +48,79 @@ function getTabsForMode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISCOVERY_PHASES = [
|
|
||||||
{ id: "big_picture", label: "Big Picture" },
|
|
||||||
{ id: "users_personas", label: "Users & Personas" },
|
|
||||||
{ id: "features_scope", label: "Features" },
|
|
||||||
{ id: "business_model", label: "Business Model" },
|
|
||||||
{ id: "screens_data", label: "Screens" },
|
|
||||||
{ id: "risks_questions", label: "Risks" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface SavedPhase {
|
|
||||||
phase: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
saved_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(dateStr?: string): string {
|
|
||||||
if (!dateStr) return "—";
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (isNaN(date.getTime())) return "—";
|
|
||||||
const diff = (Date.now() - date.getTime()) / 1000;
|
|
||||||
if (diff < 60) return "just now";
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
const days = Math.floor(diff / 86400);
|
|
||||||
if (days === 1) return "Yesterday";
|
|
||||||
if (days < 7) return `${days}d ago`;
|
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
|
||||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusTag({ status }: { status?: string }) {
|
|
||||||
const label = status === "live" ? "Live" : status === "building" ? "Building" : "Defining";
|
|
||||||
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#9a7b3a";
|
|
||||||
const bg = status === "live" ? "#2e7d3210" : status === "building" ? "#3d5afe10" : "#d4a04a12";
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
|
||||||
padding: "3px 9px", borderRadius: 4,
|
|
||||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
|
||||||
color, background: bg, fontFamily: "Outfit, sans-serif",
|
|
||||||
}}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectShell({
|
export function ProjectShell({
|
||||||
children,
|
children,
|
||||||
workspace,
|
workspace,
|
||||||
projectId,
|
projectId,
|
||||||
projectName,
|
|
||||||
projectDescription,
|
|
||||||
projectStatus,
|
|
||||||
projectProgress,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
featureCount = 0,
|
|
||||||
creationMode,
|
creationMode,
|
||||||
}: ProjectShellProps) {
|
}: ProjectShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const TABS = getTabsForMode(creationMode);
|
const TABS = getTabsForMode(creationMode);
|
||||||
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
|
const activeTab = TABS.find(t => pathname?.includes(`/${t.path}`))?.id ?? "overview";
|
||||||
const progress = projectProgress ?? 0;
|
|
||||||
|
|
||||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`/api/projects/${projectId}/save-phase`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => setSavedPhases(d.phases ?? []))
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
// Refresh every 10s while the user is chatting with Atlas
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetch(`/api/projects/${projectId}/save-phase`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => setSavedPhases(d.phases ?? []))
|
|
||||||
.catch(() => {});
|
|
||||||
}, 10_000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
|
||||||
const firstUnsavedIdx = DISCOVERY_PHASES.findIndex(p => !savedPhaseIds.has(p.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.vibn-left-sidebar { display: none !important; }
|
.vibn-left-sidebar { display: none !important; }
|
||||||
.vibn-right-panel { display: none !important; }
|
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
|
||||||
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
|
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
|
||||||
.vibn-project-header { padding: 12px 16px !important; }
|
|
||||||
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.vibn-tab-bar a { padding: 10px 10px !important; }
|
.vibn-tab-bar a { padding: 10px 10px !important; }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
|
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||||
|
|
||||||
{/* Left sidebar */}
|
{/* Left sidebar */}
|
||||||
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
|
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
|
||||||
<VIBNSidebar workspace={workspace} />
|
<VIBNSidebar workspace={workspace} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* Main column — full width */}
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
|
|
||||||
{/* Project header */}
|
{/* Tab bar — sits at the top, no header above it */}
|
||||||
<div className="vibn-project-header" style={{
|
|
||||||
padding: "18px 32px",
|
|
||||||
borderBottom: "1px solid #e8e4dc",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
background: "#fff",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 34, height: 34, borderRadius: 9,
|
|
||||||
background: "#1a1a1a12",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
}}>
|
|
||||||
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
|
|
||||||
{projectName[0]?.toUpperCase() ?? "P"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<h2 style={{
|
|
||||||
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
|
|
||||||
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
|
|
||||||
}}>
|
|
||||||
{projectName}
|
|
||||||
</h2>
|
|
||||||
<StatusTag status={projectStatus} />
|
|
||||||
</div>
|
|
||||||
{projectDescription && (
|
|
||||||
<p style={{
|
|
||||||
fontSize: "0.75rem", color: "#a09a90", marginTop: 1,
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
||||||
maxWidth: 400,
|
|
||||||
}}>
|
|
||||||
{projectDescription}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
fontSize: "0.78rem", fontWeight: 500,
|
|
||||||
color: "#1a1a1a", background: "#f6f4f0",
|
|
||||||
padding: "6px 12px", borderRadius: 6,
|
|
||||||
}}>
|
|
||||||
{progress}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="vibn-tab-bar" style={{
|
<div className="vibn-tab-bar" style={{
|
||||||
padding: "0 32px",
|
padding: "0 24px",
|
||||||
borderBottom: "1px solid #e8e4dc",
|
borderBottom: "1px solid #e8e4dc",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{TABS.map((t) => (
|
{TABS.map(t => (
|
||||||
<Link
|
<Link
|
||||||
key={t.id}
|
key={t.id}
|
||||||
href={`/${workspace}/project/${projectId}/${t.path}`}
|
href={`/${workspace}/project/${projectId}/${t.path}`}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 18px",
|
padding: "13px 16px",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
|
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
|
||||||
borderBottom: activeTab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
|
borderBottom: activeTab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
|
||||||
transition: "all 0.12s",
|
transition: "color 0.12s",
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "Outfit, sans-serif",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
display: "block",
|
display: "block",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => { if (activeTab !== t.id) (e.currentTarget as HTMLElement).style.color = "#6b6560"; }}
|
||||||
|
onMouseLeave={e => { if (activeTab !== t.id) (e.currentTarget as HTMLElement).style.color = "#a09a90"; }}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content — full width, each page manages its own layout */}
|
||||||
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
|
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel — hidden on design tab (design page has its own right panel) */}
|
|
||||||
<div className="vibn-right-panel" style={{
|
|
||||||
width: activeTab === "design" ? 0 : 230,
|
|
||||||
borderLeft: activeTab === "design" ? "none" : "1px solid #e8e4dc",
|
|
||||||
background: "#fff",
|
|
||||||
padding: activeTab === "design" ? 0 : "22px 18px",
|
|
||||||
overflow: "auto",
|
|
||||||
flexShrink: 0,
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
display: activeTab === "design" ? "none" : undefined,
|
|
||||||
}}>
|
|
||||||
{/* Right panel content — varies by creation mode */}
|
|
||||||
{(creationMode === "code-import" || creationMode === "migration") ? (
|
|
||||||
<>
|
|
||||||
<SectionLabel>
|
|
||||||
{creationMode === "migration" ? "Migration" : "Import"}
|
|
||||||
</SectionLabel>
|
|
||||||
<div style={{ fontSize: "0.78rem", color: "#a09a90", lineHeight: 1.5, marginBottom: 16 }}>
|
|
||||||
{creationMode === "migration"
|
|
||||||
? "Atlas will audit your existing product and generate a safe, phased migration plan."
|
|
||||||
: "Atlas will clone your repository and map the architecture, then suggest surfaces to build."}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Discovery phases */}
|
|
||||||
<SectionLabel>Discovery</SectionLabel>
|
|
||||||
{DISCOVERY_PHASES.map((phase, i) => {
|
|
||||||
const isDone = savedPhaseIds.has(phase.id);
|
|
||||||
const isActive = !isDone && i === firstUnsavedIdx;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={phase.id}
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
|
||||||
padding: "9px 0",
|
|
||||||
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
|
|
||||||
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
fontSize: "0.58rem", fontWeight: 700,
|
|
||||||
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
|
|
||||||
}}>
|
|
||||||
{isDone ? "✓" : isActive ? "→" : i + 1}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
|
|
||||||
}}>
|
|
||||||
{phase.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
|
||||||
|
|
||||||
{/* Captured data — summaries from saved phases */}
|
|
||||||
<SectionLabel>Captured</SectionLabel>
|
|
||||||
{savedPhases.length > 0 ? (
|
|
||||||
savedPhases.map((p) => (
|
|
||||||
<div key={p.phase} style={{ marginBottom: 14 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.62rem", color: "#2e7d32",
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
|
||||||
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
|
|
||||||
}}>
|
|
||||||
<span>✓</span><span>{p.title}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
|
|
||||||
{p.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
|
|
||||||
Atlas will capture key details here as you chat.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
|
||||||
|
|
||||||
{/* Project info — always shown */}
|
|
||||||
<SectionLabel>Project Info</SectionLabel>
|
|
||||||
{[
|
|
||||||
{ k: "Created", v: timeAgo(createdAt) },
|
|
||||||
{ k: "Last active", v: timeAgo(updatedAt) },
|
|
||||||
{ k: "Features", v: featureCount > 0 ? `${featureCount} defined` : "None yet" },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} style={{ marginBottom: 12 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.62rem", color: "#b5b0a6",
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
|
||||||
marginBottom: 3, fontWeight: 600,
|
|
||||||
}}>
|
|
||||||
{item.k}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,43 +5,138 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
productName: string;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VIBNSidebarProps {
|
interface VIBNSidebarProps {
|
||||||
workspace: string;
|
workspace: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ status }: { status?: string }) {
|
interface ProjectData {
|
||||||
const color =
|
id: string;
|
||||||
status === "live" ? "#2e7d32"
|
productName?: string;
|
||||||
: status === "building" ? "#3d5afe"
|
name?: string;
|
||||||
: "#d4a04a";
|
status?: string;
|
||||||
const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none";
|
giteaRepo?: string;
|
||||||
|
giteaRepoUrl?: string;
|
||||||
|
surfaces?: string[];
|
||||||
|
surfaceThemes?: Record<string, string>;
|
||||||
|
apps?: Array<{ name: string; path: string; coolifyServiceUuid?: string | null; domain?: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionHeading({ label, collapsed }: { label: string; collapsed: boolean }) {
|
||||||
|
if (collapsed) return null;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<div style={{
|
||||||
width: 7, height: 7, borderRadius: "50%",
|
fontSize: "0.58rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
background: color, display: "inline-block",
|
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||||
flexShrink: 0, animation: anim,
|
padding: "14px 12px 5px",
|
||||||
}} />
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SectionRow({
|
||||||
|
icon, label, href, dim, collapsed,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
dim?: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
}) {
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "center",
|
||||||
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
|
gap: 8, padding: collapsed ? "7px 0" : "5px 12px",
|
||||||
|
borderRadius: 5, textDecoration: "none",
|
||||||
|
color: dim ? "#c5c0b8" : "#4a4640",
|
||||||
|
fontSize: "0.78rem", fontWeight: 450,
|
||||||
|
transition: "background 0.1s",
|
||||||
|
width: "100%", boxSizing: "border-box" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: "0.72rem", opacity: 0.6, flexShrink: 0, width: collapsed ? "auto" : 14, textAlign: "center" }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{!collapsed && (
|
||||||
|
<span style={{
|
||||||
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
|
fontStyle: dim ? "italic" : "normal",
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} title={collapsed ? label : undefined} style={style}
|
||||||
|
onMouseEnter={e => { if (!dim) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div title={collapsed ? label : undefined} style={{ ...style, cursor: "default" }}>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionDivider() {
|
||||||
|
return <div style={{ height: 1, background: "#eae6de", margin: "8px 12px" }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Surface label map ────────────────────────────────────────────────────────
|
||||||
|
const SURFACE_LABELS: Record<string, string> = {
|
||||||
|
"marketing": "Marketing site",
|
||||||
|
"web-app": "Web app",
|
||||||
|
"admin": "Admin panel",
|
||||||
|
"api": "API layer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SURFACE_ICONS: Record<string, string> = {
|
||||||
|
"marketing": "◎",
|
||||||
|
"web-app": "⬡",
|
||||||
|
"admin": "◫",
|
||||||
|
"api": "⌁",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main sidebar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
|
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
|
||||||
const COLLAPSED_W = 56;
|
const COLLAPSED_W = 52;
|
||||||
const EXPANDED_W = 220;
|
const EXPANDED_W = 216;
|
||||||
|
|
||||||
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
// Restore collapse state from localStorage
|
// Project-specific data
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
const [apps, setApps] = useState<AppEntry[]>([]);
|
||||||
|
|
||||||
|
// Global projects list (used when NOT inside a project)
|
||||||
|
const [projects, setProjects] = useState<Array<{ id: string; productName: string; status?: string }>>([]);
|
||||||
|
|
||||||
|
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
|
||||||
|
const activeTab = pathname?.match(/\/project\/[^/]+\/([^/]+)/)?.[1] ?? null;
|
||||||
|
|
||||||
|
// Restore collapse state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem(COLLAPSED_KEY);
|
const stored = localStorage.getItem(COLLAPSED_KEY);
|
||||||
if (stored === "1") setCollapsed(true);
|
if (stored === "1") setCollapsed(true);
|
||||||
@@ -55,14 +150,30 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch global projects list (for non-project pages)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeProjectId) return;
|
||||||
fetch("/api/projects")
|
fetch("/api/projects")
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((d) => setProjects(d.projects ?? []))
|
.then(d => setProjects(d.projects ?? []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, [activeProjectId]);
|
||||||
|
|
||||||
|
// Fetch project-specific data when inside a project
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) { setProject(null); setApps([]); return; }
|
||||||
|
|
||||||
|
fetch(`/api/projects/${activeProjectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setProject(d.project ?? null))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
fetch(`/api/projects/${activeProjectId}/apps`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setApps(d.apps ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [activeProjectId]);
|
||||||
|
|
||||||
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
|
|
||||||
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
|
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
|
||||||
const isActivity = !activeProjectId && pathname?.includes("/activity");
|
const isActivity = !activeProjectId && pathname?.includes("/activity");
|
||||||
const isSettings = !activeProjectId && pathname?.includes("/settings");
|
const isSettings = !activeProjectId && pathname?.includes("/settings");
|
||||||
@@ -78,117 +189,90 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
?? "?";
|
?? "?";
|
||||||
|
|
||||||
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
|
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
|
||||||
|
|
||||||
// Don't animate on initial mount (avoid flash)
|
|
||||||
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
|
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
|
||||||
|
|
||||||
|
const base = `/${workspace}/project/${activeProjectId}`;
|
||||||
|
|
||||||
|
// Surfaces locked in on design page
|
||||||
|
const surfaces = project?.surfaces ?? [];
|
||||||
|
// Coolify/monorepo apps
|
||||||
|
const infraApps = project?.apps ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
width: w,
|
width: w, height: "100vh",
|
||||||
height: "100vh",
|
background: "#fff", borderRight: "1px solid #e8e4dc",
|
||||||
background: "#fff",
|
display: "flex", flexDirection: "column",
|
||||||
borderRight: "1px solid #e8e4dc",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "Outfit, sans-serif",
|
||||||
flexShrink: 0,
|
flexShrink: 0, overflow: "hidden",
|
||||||
overflow: "hidden",
|
transition, position: "relative",
|
||||||
transition,
|
|
||||||
position: "relative",
|
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* Logo + toggle row */}
|
{/* ── Logo + toggle ── */}
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
/* Collapsed: logo centered, toggle below it */
|
|
||||||
<div style={{ flexShrink: 0 }}>
|
<div style={{ flexShrink: 0 }}>
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: "16px 0 8px" }}>
|
<div style={{ display: "flex", justifyContent: "center", padding: "14px 0 6px" }}>
|
||||||
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
|
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
|
||||||
<div style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden" }}>
|
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden" }}>
|
||||||
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
||||||
<button
|
<button onClick={toggle} title="Expand sidebar" style={{
|
||||||
onClick={toggle}
|
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||||
title="Expand sidebar"
|
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
|
||||||
style={{
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
fontSize: "0.8rem", fontWeight: 700,
|
||||||
color: "#6b6560", width: 28, height: 22, borderRadius: 5,
|
}}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||||
fontSize: "0.82rem", fontWeight: 700, transition: "background 0.12s, color 0.12s",
|
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||||
}}
|
>›</button>
|
||||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Expanded: logo + name on left, toggle on right */
|
<div style={{ padding: "14px 10px 14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
|
||||||
<div style={{ padding: "16px 12px 16px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
|
|
||||||
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
|
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
|
||||||
<div style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
|
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
|
||||||
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "Newsreader, serif", whiteSpace: "nowrap" }}>
|
<span style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "Newsreader, serif", whiteSpace: "nowrap" }}>
|
||||||
vibn
|
vibn
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button onClick={toggle} title="Collapse sidebar" style={{
|
||||||
onClick={toggle}
|
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||||
title="Collapse sidebar"
|
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
|
||||||
style={{
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
|
||||||
color: "#6b6560", width: 26, height: 24, borderRadius: 5,
|
}}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||||
fontSize: "0.82rem", fontWeight: 700, flexShrink: 0,
|
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||||
transition: "background 0.12s, color 0.12s",
|
>‹</button>
|
||||||
}}
|
|
||||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top nav */}
|
{/* ── Top nav ── */}
|
||||||
<div style={{ padding: collapsed ? "4px 8px" : "4px 10px", flexShrink: 0 }}>
|
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}>
|
||||||
{topNavItems.map((n) => {
|
{topNavItems.map(n => {
|
||||||
const isActive = n.id === "projects" ? isProjects
|
const isActive = n.id === "projects" ? isProjects
|
||||||
: n.id === "activity" ? isActivity
|
: n.id === "activity" ? isActivity
|
||||||
: n.id === "settings" ? isSettings
|
: isSettings;
|
||||||
: false;
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link key={n.id} href={n.href} title={collapsed ? n.label : undefined} style={{
|
||||||
key={n.id}
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
href={n.href}
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
title={collapsed ? n.label : undefined}
|
gap: 8, padding: collapsed ? "8px 0" : "7px 10px",
|
||||||
style={{
|
borderRadius: 6,
|
||||||
width: "100%",
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
display: "flex",
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||||
alignItems: "center",
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
transition: "background 0.12s", textDecoration: "none",
|
||||||
gap: 9,
|
}}
|
||||||
padding: collapsed ? "9px 0" : "8px 10px",
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
borderRadius: 6,
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
background: isActive ? "#f6f4f0" : "transparent",
|
|
||||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
transition: "all 0.12s",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{ fontSize: collapsed ? "0.95rem" : "0.78rem", opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45, width: collapsed ? "auto" : 16, textAlign: "center" }}>
|
||||||
fontSize: collapsed ? "1rem" : "0.8rem",
|
|
||||||
opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45,
|
|
||||||
width: collapsed ? "auto" : 18,
|
|
||||||
textAlign: "center",
|
|
||||||
transition: "font-size 0.15s",
|
|
||||||
}}>
|
|
||||||
{n.icon}
|
{n.icon}
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && n.label}
|
{!collapsed && n.label}
|
||||||
@@ -197,91 +281,173 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#eae6de", margin: "10px 18px", flexShrink: 0 }} />
|
<div style={{ height: 1, background: "#eae6de", margin: "8px 14px", flexShrink: 0 }} />
|
||||||
|
|
||||||
{/* Projects list */}
|
{/* ── Lower section ── */}
|
||||||
<div style={{ padding: collapsed ? "2px 8px" : "2px 10px", flex: 1, overflow: "auto" }}>
|
<div style={{ flex: 1, overflow: "auto", paddingBottom: 8 }}>
|
||||||
{!collapsed && (
|
|
||||||
<div style={{
|
{activeProjectId && project ? (
|
||||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
/* ── PROJECT VIEW: 7 product layer sections ── */
|
||||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
<>
|
||||||
padding: "6px 10px 8px",
|
{/* Project name */}
|
||||||
}}>
|
{!collapsed && (
|
||||||
Projects
|
<div style={{ padding: "4px 12px 10px" }}>
|
||||||
|
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{project.productName || project.name || "Project"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 1 }}>
|
||||||
|
{project.status === "live" ? "● Live"
|
||||||
|
: project.status === "building" ? "● Building"
|
||||||
|
: "● Defining"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Apps ── */}
|
||||||
|
<SectionHeading label="Apps" collapsed={collapsed} />
|
||||||
|
{apps.length > 0 ? (
|
||||||
|
apps.map(app => (
|
||||||
|
<SectionRow
|
||||||
|
key={app.name}
|
||||||
|
icon="▢"
|
||||||
|
label={app.name}
|
||||||
|
href={project.giteaRepoUrl ? `${project.giteaRepoUrl}/src/branch/main/${app.path}` : undefined}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SectionRow icon="▢" label="No apps yet" dim collapsed={collapsed} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* ── Layouts ── */}
|
||||||
|
<SectionHeading label="Layouts" collapsed={collapsed} />
|
||||||
|
{surfaces.length > 0 ? (
|
||||||
|
surfaces.map(s => (
|
||||||
|
<SectionRow
|
||||||
|
key={s}
|
||||||
|
icon={SURFACE_ICONS[s] ?? "◌"}
|
||||||
|
label={SURFACE_LABELS[s] ?? s}
|
||||||
|
href={`${base}/design`}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SectionRow icon="◌" label="Not configured" dim collapsed={collapsed} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* ── Infrastructure ── */}
|
||||||
|
<SectionHeading label="Infrastructure" collapsed={collapsed} />
|
||||||
|
{infraApps.length > 0 ? (
|
||||||
|
infraApps.map(app => (
|
||||||
|
<SectionRow
|
||||||
|
key={app.name}
|
||||||
|
icon="◈"
|
||||||
|
label={app.domain ?? app.name}
|
||||||
|
href={app.domain ? `https://${app.domain}` : undefined}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : project?.giteaRepo ? (
|
||||||
|
<SectionRow icon="◈" label="Gitea connected" href={project.giteaRepoUrl} collapsed={collapsed} />
|
||||||
|
) : (
|
||||||
|
<SectionRow icon="◈" label="Not deployed" dim collapsed={collapsed} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* ── Growth ── */}
|
||||||
|
<SectionHeading label="Growth" collapsed={collapsed} />
|
||||||
|
<SectionRow icon="↗" label="Not set up" dim href={`${base}/grow`} collapsed={collapsed} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* ── Monetize ── */}
|
||||||
|
<SectionHeading label="Monetize" collapsed={collapsed} />
|
||||||
|
<SectionRow icon="◉" label="Not set up" dim collapsed={collapsed} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* ── Support ── */}
|
||||||
|
<SectionHeading label="Support" collapsed={collapsed} />
|
||||||
|
<SectionRow icon="?" label="Not set up" dim collapsed={collapsed} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* ── Analytics ── */}
|
||||||
|
<SectionHeading label="Analytics" collapsed={collapsed} />
|
||||||
|
<SectionRow icon="∿" label="Not set up" dim href={`${base}/insights`} collapsed={collapsed} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* ── GLOBAL VIEW: projects list ── */
|
||||||
|
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px" }}>
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{ fontSize: "0.58rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", padding: "6px 10px 8px" }}>
|
||||||
|
Projects
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projects.map(p => {
|
||||||
|
const isActive = activeProjectId === p.id;
|
||||||
|
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
|
||||||
|
return (
|
||||||
|
<Link key={p.id} href={`/${workspace}/project/${p.id}/overview`}
|
||||||
|
title={collapsed ? p.productName : undefined}
|
||||||
|
style={{
|
||||||
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
|
gap: 9, padding: collapsed ? "9px 0" : "7px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
|
color: "#1a1a1a", fontSize: "0.8rem",
|
||||||
|
fontWeight: isActive ? 600 : 450,
|
||||||
|
transition: "background 0.12s", textDecoration: "none", overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0 }} />
|
||||||
|
{!collapsed && (
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{p.productName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{projects.map((p) => {
|
|
||||||
const isActive = activeProjectId === p.id;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={p.id}
|
|
||||||
href={`/${workspace}/project/${p.id}/overview`}
|
|
||||||
title={collapsed ? p.productName : undefined}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
|
||||||
gap: 9,
|
|
||||||
padding: collapsed ? "9px 0" : "7px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
background: isActive ? "#f6f4f0" : "transparent",
|
|
||||||
color: "#1a1a1a",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: isActive ? 600 : 450,
|
|
||||||
transition: "background 0.12s",
|
|
||||||
textDecoration: "none",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StatusDot status={p.status} />
|
|
||||||
{!collapsed && (
|
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
||||||
{p.productName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User footer */}
|
{/* ── User footer ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: collapsed ? "12px 0" : "14px 18px",
|
padding: collapsed ? "10px 0" : "12px 14px",
|
||||||
borderTop: "1px solid #eae6de",
|
borderTop: "1px solid #eae6de",
|
||||||
display: "flex",
|
display: "flex", alignItems: "center",
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
gap: 9,
|
gap: 9, flexShrink: 0,
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
}}>
|
||||||
<div
|
<div title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
|
||||||
title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
width: 28, height: 28, borderRadius: "50%",
|
width: 26, height: 26, borderRadius: "50%",
|
||||||
background: "#f0ece4", display: "flex", alignItems: "center",
|
background: "#f0ece4", display: "flex", alignItems: "center",
|
||||||
justifyContent: "center", fontSize: "0.72rem", fontWeight: 600,
|
justifyContent: "center", fontSize: "0.7rem", fontWeight: 600,
|
||||||
color: "#8a8478", flexShrink: 0, cursor: "default",
|
color: "#8a8478", flexShrink: 0, cursor: "default",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{userInitial}
|
{userInitial}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{ fontSize: "0.76rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a",
|
|
||||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
||||||
}}>
|
|
||||||
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
|
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => signOut({ callbackUrl: "/auth" })} style={{
|
||||||
onClick={() => signOut({ callbackUrl: "/auth" })}
|
background: "none", border: "none", padding: 0,
|
||||||
style={{
|
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
||||||
background: "none", border: "none", padding: 0,
|
fontFamily: "Outfit, sans-serif",
|
||||||
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
}}>
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user