diff --git a/app/[workspace]/project/[projectId]/layout.tsx b/app/[workspace]/project/[projectId]/layout.tsx index 3916a82..be54a38 100644 --- a/app/[workspace]/project/[projectId]/layout.tsx +++ b/app/[workspace]/project/[projectId]/layout.tsx @@ -1,18 +1,20 @@ import { AppShell } from "@/components/layout/app-shell"; -import { getAdminDb } from "@/lib/firebase/admin"; +import { query } from "@/lib/db-postgres"; async function getProjectName(projectId: string): Promise { try { - const adminDb = getAdminDb(); - const projectDoc = await adminDb.collection('projects').doc(projectId).get(); - if (projectDoc.exists) { - const data = projectDoc.data(); - return data?.productName || data?.name || "Product"; + const rows = await query<{ data: any }>( + `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + if (rows.length > 0) { + const data = rows[0].data; + return data?.productName || data?.name || "Project"; } } catch (error) { console.error("Error fetching project name:", error); } - return "Product"; + return "Project"; } export default async function ProjectLayout({ @@ -24,11 +26,10 @@ export default async function ProjectLayout({ }) { const { workspace, projectId } = await params; const projectName = await getProjectName(projectId); - + return ( {children} ); } - diff --git a/app/[workspace]/project/[projectId]/overview/page.tsx b/app/[workspace]/project/[projectId]/overview/page.tsx index 0fa9b64..4fc825b 100644 --- a/app/[workspace]/project/[projectId]/overview/page.tsx +++ b/app/[workspace]/project/[projectId]/overview/page.tsx @@ -1,172 +1,161 @@ "use client"; import { useEffect, useState } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Activity, Clock, DollarSign, FolderOpen, Settings, Loader2, Github, MessageSquare, CheckCircle2, AlertCircle, Link as LinkIcon } from "lucide-react"; -import Link from "next/link"; import { useParams } from "next/navigation"; -import { db, auth } from "@/lib/firebase/config"; -import { doc, getDoc, collection, query, where, getDocs } from "firebase/firestore"; +import { useSession } from "next-auth/react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + GitBranch, + GitCommit, + GitPullRequest, + CircleDot, + ExternalLink, + Terminal, + Rocket, + Database, + Loader2, + CheckCircle2, + XCircle, + Clock, + AlertCircle, + Code2, + RefreshCw, +} from "lucide-react"; +import Link from "next/link"; import { toast } from "sonner"; +interface ContextSnapshot { + lastCommit?: { + sha: string; + message: string; + author?: string; + timestamp?: string; + url?: string; + }; + currentBranch?: string; + recentCommits?: { sha: string; message: string; author?: string; timestamp?: string }[]; + openPRs?: { number: number; title: string; url: string; from: string; into: string }[]; + openIssues?: { number: number; title: string; url: string; labels?: string[] }[]; + lastDeployment?: { + status: string; + url?: string; + timestamp?: string; + deploymentUuid?: string; + }; + updatedAt?: string; +} + interface Project { id: string; name: string; productName: string; productVision?: string; - workspacePath?: string; - workspaceName?: string; - githubRepo?: string; - githubRepoUrl?: string; - chatgptUrl?: string; - projectType: 'scratch' | 'existing'; - status: string; - createdAt: any; + slug?: string; + workspace?: string; + status?: string; + currentPhase?: string; + projectType?: string; + // Gitea + giteaRepo?: string; + giteaRepoUrl?: string; + giteaCloneUrl?: string; + giteaSshUrl?: string; + giteaWebhookId?: number; + giteaError?: string; + // Coolify + coolifyProjectUuid?: string; + coolifyAppUuid?: string; + coolifyDbUuid?: string; + deploymentUrl?: string; + // Theia + theiaWorkspaceUrl?: string; + // Context + contextSnapshot?: ContextSnapshot; + stats?: { sessions: number; costs: number }; + createdAt?: string; + updatedAt?: string; } -interface Stats { - totalSessions: number; - totalCost: number; - totalTokens: number; - totalDuration: number; +function timeAgo(ts?: string): string { + if (!ts) return "โ€”"; + const d = new Date(ts); + if (isNaN(d.getTime())) return "โ€”"; + const diff = (Date.now() - d.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`; + return `${Math.floor(diff / 86400)}d ago`; } -interface UnassociatedSession { - id: string; - workspacePath: string; - workspaceName: string; - count: number; +function DeployBadge({ status }: { status?: string }) { + if (!status) return No deployments; + const map: Record = { + finished: { label: "Deployed", icon: CheckCircle2, className: "bg-green-500/10 text-green-600 border-green-500/20" }, + in_progress: { label: "Deploying", icon: Loader2, className: "bg-blue-500/10 text-blue-600 border-blue-500/20" }, + queued: { label: "Queued", icon: Clock, className: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" }, + failed: { label: "Failed", icon: XCircle, className: "bg-red-500/10 text-red-600 border-red-500/20" }, + cancelled: { label: "Cancelled", icon: XCircle, className: "bg-gray-500/10 text-gray-500 border-gray-500/20" }, + }; + const cfg = map[status] ?? { label: status, icon: AlertCircle, className: "bg-gray-500/10 text-gray-500" }; + const Icon = cfg.icon; + return ( + + + {cfg.label} + + ); } export default function ProjectOverviewPage() { const params = useParams(); const projectId = params.projectId as string; const workspace = params.workspace as string; - + const { status: authStatus } = useSession(); + const [project, setProject] = useState(null); - const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [unassociatedSessions, setUnassociatedSessions] = useState(0); - const [linkingSessions, setLinkingSessions] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const fetchProject = async () => { + try { + const res = await fetch(`/api/projects/${projectId}`); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to load project"); + } + const data = await res.json(); + setProject(data.project); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + setRefreshing(false); + } + }; useEffect(() => { - const fetchProjectData = async () => { - try { - const user = auth.currentUser; - if (!user) { - setError('Not authenticated'); - setLoading(false); - return; - } + if (authStatus === "authenticated") fetchProject(); + else if (authStatus === "unauthenticated") setLoading(false); + }, [authStatus, projectId]); - // Fetch project details - const projectDoc = await getDoc(doc(db, 'projects', projectId)); - if (!projectDoc.exists()) { - setError('Project not found'); - setLoading(false); - return; - } - - const projectData = projectDoc.data() as Project; - setProject({ ...projectData, id: projectDoc.id }); - - // Fetch stats - const statsResponse = await fetch(`/api/stats?projectId=${projectId}`); - if (statsResponse.ok) { - const statsData = await statsResponse.json(); - setStats(statsData); - } - - // If project has a workspace path, check for unassociated sessions - if (projectData.workspacePath) { - try { - const sessionsRef = collection(db, 'sessions'); - const unassociatedQuery = query( - sessionsRef, - where('userId', '==', user.uid), - where('workspacePath', '==', projectData.workspacePath), - where('needsProjectAssociation', '==', true) - ); - const unassociatedSnap = await getDocs(unassociatedQuery); - setUnassociatedSessions(unassociatedSnap.size); - } catch (err) { - // Index might not be ready yet, silently fail - console.log('Could not check for unassociated sessions:', err); - } - } - } catch (err: any) { - console.error('Error fetching project:', err); - setError(err.message); - } finally { - setLoading(false); - } - }; - - const unsubscribe = auth.onAuthStateChanged((user) => { - if (user) { - fetchProjectData(); - } else { - setError('Not authenticated'); - setLoading(false); - } - }); - - return () => unsubscribe(); - }, [projectId]); - - const handleLinkSessions = async () => { - if (!project?.workspacePath) return; - - setLinkingSessions(true); - try { - const user = auth.currentUser; - if (!user) { - toast.error('You must be signed in'); - return; - } - - const token = await user.getIdToken(); - - const response = await fetch('/api/sessions/associate-project', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - projectId, - workspacePath: project.workspacePath, - workspaceName: project.workspaceName, - }), - }); - - if (response.ok) { - const data = await response.json(); - toast.success(`โœ… Linked ${unassociatedSessions} sessions to this project!`); - setUnassociatedSessions(0); - - // Refresh stats - const statsResponse = await fetch(`/api/stats?projectId=${projectId}`); - if (statsResponse.ok) { - const statsData = await statsResponse.json(); - setStats(statsData); - } - } else { - toast.error('Failed to link sessions'); - } - } catch (error) { - console.error('Error linking sessions:', error); - toast.error('An error occurred'); - } finally { - setLinkingSessions(false); - } + const handleRefresh = () => { + setRefreshing(true); + fetchProject(); }; if (loading) { return ( -
+
); @@ -174,303 +163,323 @@ export default function ProjectOverviewPage() { if (error || !project) { return ( -
-
- -
-

Error Loading Project

-

{error || 'Project not found'}

- - - +
+ + +

{error ?? "Project not found"}

+
+
); } + const snap = project.contextSnapshot; + const gitea_url = process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.vibnai.com"; + return ( -
- {/* Header */} -
-
-
-
-
๐Ÿ“ฆ
-
-

{project.productName}

-

{project.name}

-
-
- {project.productVision && ( -

{project.productVision}

- )} - {project.workspacePath && ( -
- - {project.workspacePath} -
+
+ + {/* โ”€โ”€ Header โ”€โ”€ */} +
+
+

{project.productName}

+ {project.productVision && ( +

{project.productVision}

+ )} +
+ + {project.status ?? "active"} + + {project.currentPhase && ( + {project.currentPhase} )}
- -
+
+ + {project.theiaWorkspaceUrl ? ( + - + ) : ( + + )}
- {/* Content */} -
-
- - {/* ๐Ÿ”— Link Unassociated Sessions - Show this FIRST if available */} - {unassociatedSessions > 0 && ( - - -
-
- - - Sessions Detected! - - - We found {unassociatedSessions} coding session{unassociatedSessions > 1 ? 's' : ''} from this workspace that {unassociatedSessions > 1 ? 'aren\'t' : 'isn\'t'} linked to any project yet. - + {/* โ”€โ”€ Quick Stats โ”€โ”€ */} +
+ {[ + { label: "Sessions", value: project.stats?.sessions ?? 0 }, + { label: "AI Cost", value: `$${(project.stats?.costs ?? 0).toFixed(2)}` }, + { label: "Open PRs", value: snap?.openPRs?.length ?? 0 }, + { label: "Open Issues", value: snap?.openIssues?.length ?? 0 }, + ].map(({ label, value }) => ( + + +

{value}

+

{label}

+
+
+ ))} +
+ +
+ + {/* โ”€โ”€ Code / Gitea โ”€โ”€ */} + + + + + Code Repository + + + + {project.giteaRepo ? ( + <> +
+
+ + {snap?.currentBranch ?? "main"}
- + {project.giteaRepo} + +
- - -
-

- Workspace: {project.workspacePath} -

-

- Linking these sessions will add their costs, time, and activity to this project's stats. -

+ + {snap?.lastCommit ? ( +
+
+ + {snap.lastCommit.sha.slice(0, 8)} + ยท + {timeAgo(snap.lastCommit.timestamp)} + {snap.lastCommit.author && ยท {snap.lastCommit.author}} +
+

{snap.lastCommit.message}

+
+ ) : ( +

No commits yet โ€” push to get started

+ )} + +
+

Clone

+

{project.giteaCloneUrl}

+ {project.giteaSshUrl && ( +

{project.giteaSshUrl}

+ )}
- - - )} + + ) : ( +
+

+ {project.giteaError + ? `Repo provisioning failed: ${project.giteaError}` + : "No repository linked"} +

+
+ )} + + - {/* Stats Cards - Only show if there are sessions */} - {stats && stats.totalSessions > 0 && ( -
- - - - - Total Sessions - - - -
{stats.totalSessions}
-
-
+ {/* โ”€โ”€ Deployment โ”€โ”€ */} + + + + + Deployment + + + + {snap?.lastDeployment ? ( + <> +
+ + {timeAgo(snap.lastDeployment.timestamp)} +
+ {snap.lastDeployment.url && ( + + + {snap.lastDeployment.url} + + )} + + ) : ( +
+

No deployments yet

+ +
+ )} +
+
- - - - - Total Time - - - -
{stats.totalDuration}m
-
-
+ {/* โ”€โ”€ Open PRs โ”€โ”€ */} + + + + + Pull Requests + {(snap?.openPRs?.length ?? 0) > 0 && ( + {snap!.openPRs!.length} open + )} + + + + {snap?.openPRs?.length ? ( + + ) : ( +

No open pull requests

+ )} +
+
- - - - - Total Cost - - - -
${stats.totalCost.toFixed(2)}
-
-
+ {/* โ”€โ”€ Open Issues โ”€โ”€ */} + + + + + Issues + {(snap?.openIssues?.length ?? 0) > 0 && ( + {snap!.openIssues!.length} open + )} + + + + {snap?.openIssues?.length ? ( + + ) : ( +

No open issues

+ )} +
+
- - - - - Tokens Used - - - -
{stats.totalTokens.toLocaleString()}
-
-
+
+ + {/* โ”€โ”€ Recent Commits โ”€โ”€ */} + {snap?.recentCommits && snap.recentCommits.length > 1 && ( + + + + + Recent Commits + + + +
    + {snap.recentCommits.map((c, i) => ( +
  • + {c.sha.slice(0, 8)} + {c.message} + {c.author ?? ""} + {timeAgo(c.timestamp)} +
  • + ))} +
+
+
+ )} + + {/* โ”€โ”€ Resources โ”€โ”€ */} + + + + + Resources + + Databases and services linked to this project + + + {project.coolifyDbUuid ? ( +
+ + Database provisioned + {project.coolifyDbUuid} +
+ ) : ( +
+

No databases provisioned yet

+
)} +
+
- {/* Quick Actions */} -
- - - - - - Sessions - - View all coding sessions - - - + {/* โ”€โ”€ Context snapshot freshness โ”€โ”€ */} + {snap?.updatedAt && ( +

+ Context updated {timeAgo(snap.updatedAt)} via webhooks +

+ )} - - - - - - Analytics - - Cost & usage analytics - - - - - - - - - - Connections - - Manage integrations - - - -
- - {/* Getting Started - Show if no sessions and no unassociated */} - {stats && stats.totalSessions === 0 && unassociatedSessions === 0 && ( - - -
- {/* Show different icons based on project type */} -
- {project.workspacePath && } - {project.githubRepo && } - {project.chatgptUrl && } - {!project.workspacePath && !project.githubRepo && !project.chatgptUrl && ( - - )} -
- -
- {/* Dynamic title based on project type */} - {project.workspacePath && ( - <> -

Start Coding in This Workspace!

-

- Open {project.workspacePath} in Cursor and start coding. - Sessions from this workspace will automatically appear here. -

- - )} - - {project.githubRepo && ( - <> -

Clone & Start Coding!

-

- Clone your repository and open it in Cursor to start tracking your development. -

- - - )} - - {project.chatgptUrl && ( - <> -

Turn Your Idea Into Code!

-

- Start building based on your ChatGPT conversation. Open your project in Cursor to begin tracking. -

- - - )} - - {!project.workspacePath && !project.githubRepo && !project.chatgptUrl && ( - <> -

Start Building!

-

- Create your project directory and open it in Cursor to start tracking your development. -

- - )} -
- -
- - - -
- - {/* Setup status */} -
-

Setup Checklist

-
-
- - Project created -
-
- - Install Cursor Monitor extension -
-
- - Start coding in your workspace -
-
-
-
-
-
- )} -
-
); } - diff --git a/app/[workspace]/projects/page.tsx b/app/[workspace]/projects/page.tsx index 2a4a745..f478497 100644 --- a/app/[workspace]/projects/page.tsx +++ b/app/[workspace]/projects/page.tsx @@ -5,12 +5,26 @@ import { useSession } from "next-auth/react"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, + CardDescription, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Plus, Sparkles, Loader2, MoreVertical, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Plus, + Sparkles, + Loader2, + MoreVertical, + Trash2, + GitBranch, + GitCommit, + Rocket, + Terminal, + CheckCircle2, + XCircle, + Clock, +} from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ProjectCreationModal } from "@/components/project-creation-modal"; @@ -32,6 +46,14 @@ import { } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; +interface ContextSnapshot { + lastCommit?: { sha: string; message: string; author?: string; timestamp?: string }; + currentBranch?: string; + openPRs?: { number: number; title: string }[]; + openIssues?: { number: number; title: string }[]; + lastDeployment?: { status: string; url?: string }; +} + interface ProjectWithStats { id: string; name: string; @@ -42,25 +64,45 @@ interface ProjectWithStats { status?: string; createdAt: string | null; updatedAt: string | null; + giteaRepo?: string; + giteaRepoUrl?: string; + theiaWorkspaceUrl?: string; + contextSnapshot?: ContextSnapshot; stats: { sessions: number; costs: number; }; } -function getTimeAgo(dateStr: string | null | undefined): string { - if (!dateStr) return "Unknown"; +function timeAgo(dateStr?: string | null): string { + if (!dateStr) return "โ€”"; const date = new Date(dateStr); - if (isNaN(date.getTime())) return "Unknown"; - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - if (days === 0) return "Today"; + 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} days ago`; - if (days < 30) return `${Math.floor(days / 7)} weeks ago`; - if (days < 365) return `${Math.floor(days / 30)} months ago`; - return `${Math.floor(days / 365)} years ago`; + if (days < 7) return `${days}d ago`; + if (days < 30) return `${Math.floor(days / 7)}w ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +function DeployDot({ status }: { status?: string }) { + if (!status) return null; + const map: Record = { + finished: "bg-green-500", + in_progress: "bg-blue-500 animate-pulse", + queued: "bg-yellow-400", + failed: "bg-red-500", + }; + return ( + + ); } export default function ProjectsPage() { @@ -87,7 +129,6 @@ export default function ProjectsPage() { setProjects(data.projects || []); setError(null); } catch (err: unknown) { - console.error("Error fetching projects:", err); setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); @@ -95,11 +136,8 @@ export default function ProjectsPage() { }; useEffect(() => { - if (status === "authenticated") { - fetchProjects(); - } else if (status === "unauthenticated") { - setLoading(false); - } + if (status === "authenticated") fetchProjects(); + else if (status === "unauthenticated") setLoading(false); }, [status]); const handleDeleteProject = async () => { @@ -111,7 +149,6 @@ export default function ProjectsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId: projectToDelete.id }), }); - if (res.ok) { toast.success("Project deleted"); setProjectToDelete(null); @@ -120,8 +157,7 @@ export default function ProjectsPage() { const err = await res.json(); toast.error(err.error || "Failed to delete project"); } - } catch (err) { - console.error("Delete error:", err); + } catch { toast.error("An error occurred"); } finally { setIsDeleting(false); @@ -139,12 +175,11 @@ export default function ProjectsPage() { return ( <>
+ {/* Header */}

Projects

-

- {session?.user?.email} -

+

{session?.user?.email}

-
- {loading && ( -
- -
- )} + {/* States */} + {loading && ( +
+ +
+ )} - {error && ( - - -

Error: {error}

-
-
- )} + {error && ( + + +

Error: {error}

+
+
+ )} - {!loading && !error && projects.length > 0 && ( -
-

Your Projects

-
- {projects.map((project) => { - const projectHref = `/${workspace}/project/${project.id}/overview`; - return ( -
- - - -
-
-
๐Ÿ“ฆ
-
- {project.productName} - - {getTimeAgo(project.updatedAt)} - -
-
-
-
0 && ( +
+ {projects.map((project) => { + const href = `/${workspace}/project/${project.id}/overview`; + const snap = project.contextSnapshot; + const deployStatus = snap?.lastDeployment?.status; + + return ( +
+ + + +
+
+ {project.productName} + + {timeAgo(project.updatedAt)} + +
+
+ + {project.status ?? "active"} + + + e.preventDefault()}> + + + + { + e.preventDefault(); + setProjectToDelete(project); + }} > - {project.status} -
- - e.preventDefault()}> - - - - { - e.preventDefault(); - setProjectToDelete(project); - }} - > - - Delete Project - - - -
+ + Delete + + + +
+
+ + + + {/* Vision */} + {project.productVision && ( +

+ {project.productVision} +

+ )} + + {/* Gitea repo + last commit */} + {project.giteaRepo && ( +
+
+ + {project.giteaRepo} + {snap?.currentBranch && ( + + {snap.currentBranch} + + )}
- - -

- {project.productVision || "No description"} -

- {project.workspacePath && ( -

- ๐Ÿ“ {project.workspacePath.split("/").pop()} -

+ {snap?.lastCommit ? ( +
+ + {snap.lastCommit.sha.slice(0, 7)} + {snap.lastCommit.message} + {timeAgo(snap.lastCommit.timestamp)} +
+ ) : ( +

No commits yet

)} -
-
- Sessions:{" "} - {project.stats.sessions} -
-
- Costs:{" "} - ${project.stats.costs.toFixed(2)} -
-
-
- - -
- ); - })} +
+ )} - setShowCreationModal(true)} - > - -
- -
-

Create New Project

-

- Start tracking a new product -

-
-
-
-
- )} - - {!loading && !error && projects.length === 0 && ( - - -
- + {/* Footer row: deploy + stats + IDE */} +
+
+ {deployStatus && ( + + + {deployStatus === "finished" ? "Live" : deployStatus} + + )} + {project.stats.sessions} sessions + ${project.stats.costs.toFixed(2)} +
+ {project.theiaWorkspaceUrl && ( + e.stopPropagation()} + className="flex items-center gap-1 text-[10px] text-primary hover:underline" + > + + IDE + + )} +
+ + +
-

No projects yet

-

- Create your first project to start tracking your AI-powered development workflow. + ); + })} + + {/* Create card */} + setShowCreationModal(true)} + > + +

+ +
+

New Project

+

+ Auto-provisions a Gitea repo and workspace

-
- )} -
+
+ )} + + {/* Empty state */} + {!loading && !error && projects.length === 0 && ( + + +
+ +
+

No projects yet

+

+ Create your first project. Vibn will automatically provision a Gitea repo, + register webhooks, and prepare your IDE workspace. +

+ +
+
+ )}
!open && setProjectToDelete(null)}> - Are you absolutely sure? + Delete "{projectToDelete?.productName}"? - This will delete "{projectToDelete?.productName}". Sessions will be preserved but unlinked. + This will remove the project record. Sessions will be preserved but unlinked. + The Gitea repo will not be deleted automatically. Cancel - - {isDeleting ? : } + + {isDeleting ? ( + + ) : ( + + )} Delete Project