Compare commits

..

136 Commits

Author SHA1 Message Date
74f8dc4282 fix(ui): make Justine palette visible on marketing + trim rainbow chrome
- Replace blue/purple gradients with ink gradient text and cream/parch CTA surface
- Step badges and transformation icons use primary (ink) fills
- /features page icons unified to text-primary; Lora section titles
- Tree view status colors use semantic tokens instead of blue/green

Made-with: Cursor
2026-04-01 21:09:18 -07:00
bada63452f feat(ui): apply Justine ink & parchment design system
- Map Justine tokens to shadcn CSS variables (--vibn-* aliases)
- Switch fonts to Inter + Lora via next/font (IBM Plex Mono for code)
- Base typography: body Inter, h1–h3 Lora; marketing hero + wordmark serif
- Project shell and global chrome use semantic colors
- Replace Outfit/Newsreader references across TSX inline styles

Made-with: Cursor
2026-04-01 21:03:40 -07:00
06238f958a fix(entrypoint): drop prisma db push; add NextAuth DDL to SQL bootstrap
prisma db push compared DB to schema-only NextAuth models and proposed
dropping fs_*, agent_*, atlas_*, etc. on every container start.
Use CREATE TABLE IF NOT EXISTS for users/accounts/sessions/verification_tokens
plus existing app tables — same pattern as admin migrate.

Made-with: Cursor
2026-04-01 13:10:00 -07:00
26429f3517 feat(agent): event timeline API, SSE stream, Coolify DDL, env template
- Add agent_session_events table + GET/POST events + SSE stream routes
- Build Agent tab: hydrate from events + EventSource while running
- entrypoint: create agent_sessions + agent_session_events on container start
- .env.example for AGENT_RUNNER_URL, AGENT_RUNNER_SECRET, DATABASE_URL

Made-with: Cursor
2026-04-01 11:48:55 -07:00
a11caafd22 feat: add 'Generate architecture' CTA banner on PRD page when arch not yet generated
Made-with: Cursor
2026-03-17 17:23:38 -07:00
8eb6c149cb fix: map Non-Functional Reqs to features_scope phase, remove circular 'Generated when PRD finalized' hint
Made-with: Cursor
2026-03-17 17:15:26 -07:00
062e836ff9 fix: always show chat on Vibn tab — swap empty 'done' state for thin PRD-ready notice bar
Made-with: Cursor
2026-03-17 17:10:06 -07:00
d9bea73032 feat: add quick-action chips above chat input (suggestions, importance, move on)
Made-with: Cursor
2026-03-17 17:02:40 -07:00
532f851d1f ux: skip type selector — new project goes straight to name input
- CreateProjectFlow now defaults to setup/fresh mode; type selector never shown
- FreshIdeaSetup simplified to just project name + Start button
  (removed description field, 6-phase explanation copy, SetupHeader)

Made-with: Cursor
2026-03-17 16:58:35 -07:00
f1b4622043 fix: make content wrapper a flex column so child pages can scroll
Made-with: Cursor
2026-03-17 16:40:24 -07:00
f47205c473 rename: replace all user-facing 'Atlas' references with 'Vibn'
Updated UI text in: project-shell (tab label), AtlasChat (sender name),
FreshIdeaMain, TypeSelector, MigrateSetup, ChatImportSetup, FreshIdeaSetup,
CodeImportSetup, prd/page, build/page, projects/page, deployment/page,
activity/page, layout (page title/description), atlas-chat API route.
Code identifiers (AtlasChat component name, file names) unchanged.

Made-with: Cursor
2026-03-17 16:25:41 -07:00
f9f3156d49 fix: PRD tracker shows sections complete (0 of 12), not internal phases
Made-with: Cursor
2026-03-17 16:22:02 -07:00
2e3b405893 feat: restore PRD section tracker on the right side of Atlas chat
Two-column layout on the Atlas tab:
- Left: Atlas discovery chat (full height, flex 1)
- Right: 240px PRD section panel showing all 12 sections with
  live status dots (filled = phase saved, empty = pending)
  plus a progress bar showing phases complete out of 6
- Discovery banner (all 6 done) now lives inside the left column
- "Generate PRD" footer CTA appears in right panel when all done

Made-with: Cursor
2026-03-17 16:15:06 -07:00
9e20125938 revert: restore Atlas|PRD|Build|Growth|Assist|Analytics tab nav, remove COO sidebar
- SECTIONS back to 6 tabs: Atlas → /overview, PRD, Build, Growth, Assist, Analytics
- Remove persistent CooChat left panel and drag-resize handle
- Content area is now full-width again (no 320px sidebar eating space)
- Clean up unused imports (useSearchParams, useRouter, CooChat, Lucide icons, TOOLS constant)

Made-with: Cursor
2026-03-17 16:03:19 -07:00
317abf047b Fix auth redirect to use session email instead of hardcoded workspace
New users were being sent to /marks-account/projects. Now derives
workspace from the signed-in user's email so everyone lands on
their own workspace after Google OAuth.

Made-with: Cursor
2026-03-16 21:39:19 -07:00
63dded42a6 fix: repair JSX parse error in PRD page (nested ternary → && conditionals)
Made-with: Cursor
2026-03-10 17:29:05 -07:00
46efc41812 feat: add Architecture tab to PRD page and inject arch into COO context
- PRD page now has a tabbed view: PRD | Architecture
  Architecture tab renders apps, packages, infrastructure, integrations,
  and risk notes as structured cards. Only shown when arch doc exists.
- Advisor route now includes the architecture summary and key fields
  in the COO's knowledge context so the orchestrator knows what's
  been planned technically

Made-with: Cursor
2026-03-10 17:03:43 -07:00
c35e7dbe56 feat: draggable resize handle on the CooChat sidebar
Made-with: Cursor
2026-03-10 16:38:13 -07:00
cff5cd6014 fix: pass full PRD to COO without truncation
Made-with: Cursor
2026-03-10 16:36:47 -07:00
99c1a83b9f feat: load Atlas discovery history into CooChat sidebar
Eliminates the two-chat experience on the overview page:

- CooChat now pre-loads Atlas conversation history on mount, showing
  the full discovery conversation in the left panel. Atlas messages
  render with a blue "A" avatar; COO messages use the dark "◈" icon.
  A "Discovery · COO" divider separates historical from new messages.
- FreshIdeaMain detects when a PRD already exists and replaces the
  duplicate AtlasChat with a clean completion view ("Discovery complete")
  that links to the PRD and Build pages. Atlas chat only shows when
  discovery is still in progress.

Made-with: Cursor
2026-03-10 16:28:44 -07:00
8f95270b12 feat: Assist COO routes through Orchestrator on agent runner
The advisor route now proxies to /orchestrator/chat on agents.vibnai.com
instead of calling Gemini directly. The Orchestrator (Claude Sonnet 4.6)
has full tool access — Gitea, Coolify, web search, memory, agent spawning.

- Build project knowledge_context from DB (name, vision, repo, PRD,
  phases, apps, recent sessions) and inject as COO persona + data
- Convert frontend history format (model→assistant) for the orchestrator
- Return orchestrator reply as streaming text response
- Session scoped per project for in-memory context persistence

Made-with: Cursor
2026-03-09 22:32:01 -07:00
ff0e1592fa feat(advisor): load real PRD, phases, sessions, apps into COO system prompt
Made-with: Cursor
2026-03-09 22:14:35 -07:00
1af5595e35 feat(tasks): move Tasks first in toolbar, add Tasks+PRD left nav and content
Made-with: Cursor
2026-03-09 22:02:01 -07:00
e3c6b9a9b4 feat(create): show only Fresh Idea and Import Chats project types
Made-with: Cursor
2026-03-09 19:02:25 -07:00
528d6bb1e3 fix: remove colon from Coolify project description — fails Coolify validation
Made-with: Cursor
2026-03-09 18:20:33 -07:00
2aace73e33 fix(docker): copy scaffold templates into runner stage for fresh project creation
Made-with: Cursor
2026-03-09 18:17:14 -07:00
6901a97db3 feat(migrate): wire GitHub PAT through to agent runner mirror call
MigrateSetup now sends the PAT field to the API; create route
forwards it as github_token so the agent runner can clone private repos.

Made-with: Cursor
2026-03-09 18:05:12 -07:00
3e9bf7c0e0 fix: use correct Coolify server UUID — was hardcoded '0' which doesn't exist
Made-with: Cursor
2026-03-09 17:52:58 -07:00
0e204ced89 feat: store coolifyProjectUuid on project creation for Infrastructure panel
Made-with: Cursor
2026-03-09 17:40:21 -07:00
7979fd0518 fix: detect apps in any repo structure, not just turborepo or flagged imports
Made-with: Cursor
2026-03-09 17:23:38 -07:00
22f4c4f1c3 fix: preview URL resolved from Gitea repo via Coolify git_repository match
Made-with: Cursor
2026-03-09 17:14:55 -07:00
5778abe6c3 feat: add live app preview panel with iframe, URL bar, and reload
Made-with: Cursor
2026-03-09 17:07:33 -07:00
70c94dc60c feat: tool icons drive left nav section, remove inner pills
Made-with: Cursor
2026-03-09 16:49:47 -07:00
57c0744b56 feat: move tool icons adjacent to section pills, add active toggle state
Made-with: Cursor
2026-03-09 16:31:38 -07:00
aa23a552c4 feat: replace unicode tool icons with Lucide icons (Globe, Cloud, Palette, Code2, BarChart2)
Made-with: Cursor
2026-03-09 16:28:19 -07:00
853e41705f feat: split top navbar to align with chat/content panels, fix Gemini API key
- Top bar left section (320px) = logo + project name, aligns with chat panel
- Top bar right section = Build|Market|Assist pills + tool icons (Preview, Tasks, Code, Design, Backend) + avatar
- Read GOOGLE_API_KEY inside POST handler (not top-level) to ensure env is resolved at request time

Made-with: Cursor
2026-03-09 16:17:31 -07:00
1ef3f9baa3 feat: top navbar (Build|Market|Assist) + persistent Assist chat in shell
- New top navbar in ProjectShell: logo + project name | Build | Market |
  Assist tabs | user avatar — replaces the left icon sidebar for project pages
- CooChat extracted to components/layout/coo-chat.tsx and moved into the
  shell so it persists across Build/Market/Assist route changes
- Build page inner layout simplified: inner nav (200px) + file viewer,
  no longer owns the chat column
- Layout: [top nav 48px] / [Assist chat 320px | content flex]

Made-with: Cursor
2026-03-09 15:51:48 -07:00
01848ba682 feat: add persistent COO/Assist chat as left-side primary AI interface
- New CooChat component: streaming Gemini-backed advisor chat, message
  bubbles, typing cursor animation, Shift+Enter for newlines
- New /api/projects/[projectId]/advisor streaming endpoint: builds a
  COO system prompt from project context (name, description, vision,
  repo), proxies Gemini SSE stream back to the client
- Restructured BuildHubInner layout:
    Left (340px): CooChat — persistent across all Build sections
    Inner nav (200px): Build pills + contextual items (apps, tree, surfaces)
    Main area: File viewer for Code, Layouts content, Infra content
- AgentMode removed from main view — execution surfaces via COO delegation

Made-with: Cursor
2026-03-09 15:34:41 -07:00
86f8960aa3 refactor: redesign Build page layout — sidebar nav+tree, agent as main, file viewer on right
- B (left sidebar, 260px): project header, Build pills (Code/Layouts/Infra),
  app list, file tree embedded below active app
- D (center): AgentMode as primary content; sessions shown as a horizontal
  chip strip at the top instead of a 220px left sidebar
- Right (460px): FileViewer — shows file selected in B's tree / code changes
- F (bottom): Terminal collapsible strip unchanged
- Split CodeContent into FileTree + FileViewer components; lifted file
  selection state to BuildHubInner so B and Right share it

Made-with: Cursor
2026-03-09 15:00:28 -07:00
2e0bc95bb0 refactor: replace code mode tabs with persistent Browse | Agent split + collapsible terminal
Removes the Browse/Agent/Terminal tab switcher from the code section.
Browse (file tree + viewer) is now the left pane, Agent chat is a
fixed 420px right pane, and Terminal is a collapsible strip at the
bottom — all visible simultaneously.

Made-with: Cursor
2026-03-09 14:29:35 -07:00
01c2d33208 fix: strip backticks from CODEBASE_MAP.md path parsing
Paths wrapped in backticks like `app/` were being captured with
the backtick character, producing invalid app names and paths.

Made-with: Cursor
2026-03-09 14:21:25 -07:00
65adcd4897 feat: detect apps for imported non-turborepo projects
- Fall back to CODEBASE_MAP.md parsing when no apps/ dir exists
- Further fallback: scan top-level dirs for deployable app signals
  (package.json, Dockerfile, requirements.txt, next.config.*, etc.)
- Skips docs, scripts, keys, and other non-app directories
- Returns isImport flag to frontend for context

Made-with: Cursor
2026-03-09 11:52:10 -07:00
01dd9fda8e fix: wire MigrateSetup repoUrl to githubRepoUrl for mirror flow
Made-with: Cursor
2026-03-09 11:47:41 -07:00
9c277fd8e3 feat: add GitHub import flow, project delete fix, and analyze API
- Mirror GitHub repos to Gitea as-is on import (skip scaffold)
- Auto-trigger ImportAnalyzer agent after successful mirror
- Add POST/GET /api/projects/[projectId]/analyze route
- Fix project delete button visibility (was permanently opacity:0)
- Store isImport, importAnalysisStatus, importAnalysisJobId on projects

Made-with: Cursor
2026-03-09 11:30:51 -07:00
231aeb4402 move project tabs to sidebar, remove top tab bar
Made-with: Cursor
2026-03-08 13:00:54 -07:00
fc59333383 feat: auto-approve UI + session status approved
- sessions POST: look up coolifyServiceUuid, pass autoApprove:true to runner
- sessions PATCH: approved added to terminal statuses (sets completed_at)
- build/page.tsx: approved status, STATUS_COLORS/LABELS for "Shipped",
  auto-committed UI in changed files panel, bottom bar for approved state
- Architecture doc: fully updated with current state

Made-with: Cursor
2026-03-07 13:17:33 -08:00
7b228ebad2 fix(agent): context-aware task input, auto-select active session
- Running/pending: input locked with "agent is working" message
- Done: shows "+ Follow up" and "New task" buttons instead of open input
- No session: normal new-task input (unchanged UX)
- On mount: auto-selects the most recent running/pending session,
  falls back to latest session — so navigating away and back doesn't
  lose context and doesn't require manual re-selection

Made-with: Cursor
2026-03-07 13:01:16 -08:00
7f61295637 fix: remove ::uuid casts on project_id/p.id in all agent session routes
Made-with: Cursor
2026-03-07 12:44:45 -08:00
8c19dc1802 feat: agent session retry + follow-up UX
- retry/route.ts: reset failed/stopped session and re-fire agent runner
  with optional continueTask follow-up text
- build/page.tsx: Retry button and Follow up input appear on failed/stopped
  sessions so users can continue without losing context or creating a
  duplicate session; task input hint clarifies each Run = new session

Made-with: Cursor
2026-03-07 12:25:58 -08:00
28b48b74af fix: surface agent_sessions 500 and add db migration
- sessions/route.ts: replace inline CREATE TABLE DDL with a lightweight
  existence check; add `details` to all 500 responses; fix type-unsafe
  `p.id = $1::uuid` comparisons to `p.id::text = $1` to avoid the
  Postgres `text = uuid` operator error
- app/api/admin/migrate: one-shot idempotent migration endpoint secured
  with ADMIN_MIGRATE_SECRET, creates fs_* tables + agent_sessions
- scripts/migrate-fs-tables.sql: formal schema for all fs_* tables

Made-with: Cursor
2026-03-07 12:16:16 -08:00
f7d38317b2 fix: add ::uuid casts to all agent_sessions queries
PostgreSQL can't implicitly coerce text params to UUID columns.
Add explicit ::uuid casts on id and project_id in all agent session
routes (list, get, patch, stop, approve).

Made-with: Cursor
2026-03-07 11:49:40 -08:00
18f61fe95c approve & commit flow + adaptive polling in Agent mode
- Wire Approve & commit button: shows commit message input, calls
  POST /api/.../sessions/[id]/approve which asks agent runner to
  git commit + push, then marks session as approved in DB
- Adaptive polling: 500ms while session running, 5s when idle —
  output feels near-real-time without hammering the API
- Auto-refresh session list when a session completes
- Open in Theia links to theia.vibnai.com (escape hatch for manual edits)

Made-with: Cursor
2026-03-07 11:36:55 -08:00
61a43ad9b4 pass giteaRepo to agent runner; add runner secret auth on PATCH
- Sessions route now reads giteaRepo from project.data and forwards it
  to /agent/execute so the runner can clone/update the correct repo
- PATCH route now validates x-agent-runner-secret header to prevent
  unauthorized session output injection

Made-with: Cursor
2026-03-06 18:01:33 -08:00
ad3abd427b feat: agent execution scaffold — sessions DB, API, and Browse/Agent/Terminal UI
Session model:
- agent_sessions table (auto-created on first use): id, project_id,
  app_name, app_path, task, status, output (JSONB log), changed_files,
  error, timestamps
- POST /agent/sessions — create session, fires off to agent-runner
  (gracefully degrades when runner not yet wired)
- GET  /agent/sessions — list sessions newest first
- GET  /agent/sessions/[id] — full session state for polling
- PATCH /agent/sessions/[id] — internal: agent-runner appends output lines
- POST /agent/sessions/[id]/stop — stop running session

Build > Code section now has three mode tabs:
- Browse — existing file tree + code viewer
- Agent — task input, session list sidebar, live output stream,
           changed files panel, Approve & commit / Open in Theia actions,
           2s polling (Phase 3 will replace with WebSocket)
- Terminal — xterm.js placeholder (Phase 4)

Architecture documented in AGENT_EXECUTION_ARCHITECTURE.md

Made-with: Cursor
2026-03-06 17:56:10 -08:00
93a2b4a0ac refactor: strip sidebar down to project name + status only
Removed all product layer sections (Build, Layouts, Infrastructure,
Growth, Monetize, Support, Analytics) from the left sidebar — these
are now handled by the in-page left nav inside each tab.

Sidebar now shows: logo, Projects/Activity/Settings global nav,
project name + colored status dot when inside a project, and the
user avatar/sign out at the bottom. Nothing else.

Cleaned up all dead code: SectionHeading, SectionRow, SectionDivider,
SURFACE_LABELS, SURFACE_ICONS, AppEntry interface, apps state,
apps fetch, surfaces/infraApps variables.

Made-with: Cursor
2026-03-06 17:36:31 -08:00
3cd477c295 feat: restructure project nav to Atlas | PRD | Build | Growth | Assist | Analytics
Tab bar:
- Removed: Design, Launch, Grow, Insights, Settings tabs
- Added: Growth, Assist, Analytics as top-level tabs
- Build remains, now a full hub

Build hub (/build):
- Left sub-nav groups: Code (apps), Layouts (surfaces), Infrastructure (6 items)
- Code section: scoped file browser per selected app
- Layouts section: surface overview cards with Edit link to /design
- Infrastructure section: summary panel linking to /infrastructure?tab=

Growth (/growth):
- Left nav: Marketing Site, Communications, Channels, Pages
- Each section: description + feature item grid + feedback CTA

Assist (/assist):
- Left nav: Emails, Chat Support, Support Site, Communications
- Each section: description + feature item grid + feedback CTA

Analytics (/analytics):
- Left nav: Customers, Usage, Events, Reports
- Each section: description + feature item grid + feedback CTA

Made-with: Cursor
2026-03-06 14:36:11 -08:00
3770ba1853 feat: Infrastructure section with 6 sub-sections (Builds, Databases, Services, Environment, Domains, Logs)
- Sidebar Infrastructure replaced with 6 named rows linking to /infrastructure?tab=
- New /infrastructure page with left sub-nav and per-tab content panels:
  Builds — lists deployed Coolify apps with live status
  Databases — coming soon placeholder
  Services — coming soon placeholder
  Environment — variable table with masked values (scaffold)
  Domains — lists configured domains with SSL status
  Logs — dark terminal panel, ready to stream
- Dim state on rows reflects whether data exists (e.g. no domains = dim)

Made-with: Cursor
2026-03-06 14:18:03 -08:00
39167dbe45 feat: deep-link sidebar Layouts to specific design surface
- Sidebar Layouts items now link to /design?surface=<surfaceId>
- Design page reads ?surface= param and opens that surface directly
- DesignPage split into DesignPageInner + Suspense wrapper so
  useSearchParams works in the Next.js static build

Made-with: Cursor
2026-03-06 14:12:29 -08:00
812645cae8 feat: scope Build file browser to selected app, rename Apps → Build
- Sidebar "Apps" section renamed to "Build"
- Each app now links to /build?app=<name>&root=<path> so the browser
  opens scoped to that app's subdirectory only
- Build page shows an empty-state prompt when no app is selected
- File tree header shows the selected app name, breadcrumb shows
  relative path within the app (strips the root prefix)
- Wraps useSearchParams in Suspense for Next.js static rendering

Made-with: Cursor
2026-03-06 13:51:01 -08:00
e08fcf674b feat: VIBN-branded file browser on Build tab + sidebar status dot
- Build page: full file tree (lazy-load dirs) + code preview panel
  with line numbers and token-level syntax colouring (VS Code dark theme)
- New API route /api/projects/[id]/file proxies Gitea contents API
  returning directory listings or decoded file content
- Sidebar Apps section now links to /build instead of raw Gitea URL
- Status indicator replaced with a proper coloured dot (amber/blue/green)
  alongside the status label text

Made-with: Cursor
2026-03-06 13:37:38 -08:00
bb021be088 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
2026-03-06 13:26:08 -08:00
ab100f2e76 feat: implement 4 project type flows with unique AI experiences
- New multi-step CreateProjectFlow replaces 2-step modal with TypeSelector
  and 4 setup components (Fresh Idea, Chat Import, Code Import, Migrate)
- overview/page.tsx routes to unique main component per creationMode
- FreshIdeaMain: wraps AtlasChat with post-discovery decision banner
  (Generate PRD vs Plan MVP Test)
- ChatImportMain: 3-stage flow (intake → extracting → review) with
  editable insight buckets (decisions, ideas, questions, architecture, users)
- CodeImportMain: 4-stage flow (input → cloning → mapping → surfaces)
  with architecture map and surface selection
- MigrateMain: 5-stage flow with audit, review, planning, and migration
  plan doc with checkbox-tracked tasks and non-destructive warning banner
- New API routes: analyze-chats, analyze-repo, analysis-status,
  generate-migration-plan (all using Gemini)
- ProjectShell: accepts creationMode prop, filters/renames tabs per type
  (code-import hides PRD, migration hides PRD/Grow/Insights, renames Atlas tab)
- Right panel adapts content based on creationMode

Made-with: Cursor
2026-03-06 12:48:28 -08:00
24812df89b design-surfaces: explicit ::text cast on every query param
Add ::text cast to all $1/$2 parameters so PostgreSQL never needs
to infer types. Split SELECT and UPDATE into separate try/catch blocks
with distinct error labels so logs show exactly which query fails.

Made-with: Cursor
2026-03-06 11:29:57 -08:00
53b098ce6a Fix Lock In 42P18: cast id::text to resolve parameter type ambiguity
PostgreSQL could not determine the type of $2 in 'WHERE id = $2'
when id column type is UUID. Casting the column (id::text = $1)
sidesteps the extended-protocol type inference issue. Also moves
projectId to $1 to match the proven working pattern in other routes.

Made-with: Cursor
2026-03-06 11:23:31 -08:00
69eb3b989c Fix BgLayer SVG gradient reference causing dark rectangle in light mode
- beams: replaced SVG gradient <rect fill='url(#bm-glow)'> with a CSS
  radial-gradient div — browser SVG gradient reference fallback is solid
  black which produced the dark rectangle. Also adapt line colors and
  opacity for light mode.
- meteors: switch tail gradient from white-tip to dark-tip in light mode
  so meteors are visible on a light background.
- wavy: remove SVG linearGradient id references (same black-fill risk);
  use inline hex alpha on fill instead.

Made-with: Cursor
2026-03-06 11:17:13 -08:00
7eaf1ca4f1 Filter color palettes by dark/light mode
- Add themeMode?: 'dark'|'light' to ThemeColor (unset = any mode)
- Tag all DaisyUI themes: 11 dark (synthwave, aqua, luxury, night, etc.)
  and 6 light (light, cupcake, valentine, cyberpunk, retro, winter)
- Tag HeroUI Marketing themes: purple/blue/teal/modern=light, dark=dark
- Aceternity accent palettes stay untagged (work with either mode)
- Filter availableColorThemes in SurfaceSection by designConfig.mode
- Auto-reset active palette when mode switches makes previously
  selected palette incompatible

Made-with: Cursor
2026-03-06 11:03:22 -08:00
5e4cce55de Fix Lock In 500 error: fs_projects has no updated_at column
The PATCH handler used SQL 'updated_at = NOW()' which doesn't exist
on fs_projects (all timestamps live inside the data JSONB blob).
Rewrote to use the same read-merge-write pattern as other working
routes: fetch current data, merge in JS, write back as data::jsonb.

Made-with: Cursor
2026-03-06 10:56:21 -08:00
4eff014ae6 Fix Aceternity gradient background in light mode
BgLayer 'gradient' always rendered rgb(8,0,20) dark base regardless
of mode, covering the container's light bg and making the dark
gradient-text h1 invisible. Split into isDark branches: dark mode
keeps the hard-light blob effect, light mode renders soft pastel
blobs on rgb(248,247,255).

Made-with: Cursor
2026-03-06 10:53:23 -08:00
57a4f358d1 QA: fix dark/light mode rendering across all scaffolds
Aceternity (critical — light mode was completely broken):
- text/muted/card/border now respond to isDark instead of only forcedLight
- gradient-text h1 was white→transparent gradient (invisible on light bg);
  now switches to indigo gradient in light mode
- Minimal nav background was hardcoded dark; now adapts per isDark
- Floating nav background adapts per isDark
- "Browse components" button bg adapts per isDark
- 9x hardcoded color:"#fff" on content text replaced with color:text
  (lamp h1, typewriter word spans, feature titles, moving card names,
   bento MRR/Users/Uptime values, pricing prices, CTA heading, nav logo)

DaisyUI:
- noise background option now renders a visible SVG fractalNoise pattern

Made-with: Cursor
2026-03-06 10:47:45 -08:00
a1b605febf Design panel: correct order + fix Lock In saving
- Right panel order now: Lock → Library → Mode → Colour → Font →
  Background → Nav → Hero → Sections
- Lock In was always disabled because selectedThemeId was null until
  user explicitly clicked a library button; now uses previewId (which
  defaults to first theme) for the disabled check
- Added useEffect to notify parent of the default library selection on
  mount so handleLock always has a theme to save
- handleLock also falls back to first theme as double safety net

Made-with: Cursor
2026-03-06 10:39:11 -08:00
ef9f5a6ad3 UX: all sections on by default, palette at top, fix font loading
- All library defaultConfigs now enable every available section
- Color palette moved above Library picker in right panel (top of mind)
- Added fontImport() helper that injects Google Fonts @import into each
  scaffold's style tag so Plus Jakarta, DM Sans, Geist, Inter, Nunito
  actually load instead of falling back to system-ui

Made-with: Cursor
2026-03-06 10:31:37 -08:00
eff75a1ab5 Scale all marketing scaffolds to full website proportions
Remove compact/condensed sizing — all four scaffolds now render at real
website scale (72-80px headlines, 15-18px body, 80px section padding,
48-52px buttons, 64px navs, 340px DashMockup height) so the preview
scrolls naturally rather than squishing everything to fit the viewport.

Made-with: Cursor
2026-03-06 10:21:22 -08:00
0a237e1e8f Ground-up rewrite of all 4 marketing scaffolds to premium SaaS quality
Core problems fixed:
- Emoji icons (🔒📈) removed → replaced with clean inline SVG paths
- No product visualization → all heroes now include a real DashMockup component
- Generic flat sections → proper shadowed cards, feature lists, star testimonials
- Small unimpressive text → 42-56px display headlines with tight letter-spacing

New shared infrastructure:
- DashMockup: browser chrome + sidebar + 3 KPI cards + gradient line chart
  with accent colour theming; used in DaisyUI/HeroUI/Aceternity heroes
- Ico/ICO system: 12 SVG icons (bolt, shield, trend, globe, code, layers,
  clock, target, cpu, zap, users, sparkle) replace all emoji

DaisyUI improvements:
- 3 hero layouts: centered (CTA + full-width dashboard), split (left text +
  right dashboard), stats (big metrics row + dashboard)
- Feature section: 6 cards with SVG icons, proper copy, realistic shadows
- Steps section: numbered with editorial style
- Testimonials: 4 cards with star ratings and avatar initials
- Pricing: 3 tiers with full feature bullet lists and checkmarks
- FAQ: expand/collapse style

Aceternity improvements:
- Hero text increased to 48-52px
- Floating tilted card animations via CSS keyframes (ace-float, ace-float2)
- Feature cards with SVG icons instead of emoji
- Better moving-cards testimonials (wider, more realistic copy)
- Better bento grid with real chart

HeroUI improvements:
- All 3 header styles now include DashMockup (animated-badge, split, gradient)
- Feature section: 2-col with left-aligned icon + text (not centered emoji)
- Metrics bento with 4 KPIs
- Avatar trust stack with initials
- Pricing with popular tag

Tailwind improvements:
- Editorial header: huge 56px headline + real terminal mockup
  (git push → build 2.1s → deployed to prod) with syntax highlighting
- Split header: text + terminal side by side
- Feature grid: 6 cards with SVG icons
- Stats bar with 4 metrics
- Pricing: 3 tiers with checkmark feature lists
- Inverted CTA banner (bg=text, color=bg)

Made-with: Cursor
2026-03-05 21:34:57 -08:00
e95761cc61 Add Lines Gradient Shader + fix Aurora/Sparkles/Meteors to match real Aceternity visuals
- New 'shader' background: bold diagonal purple→pink→orange→yellow gradient
  with subtle repeating line overlay (mirrors ui.aceternity.com lines-gradient-shader)
- Aurora background: now renders on light bg (#f8f9ff) with soft lavender/blue blurs
- Sparkles: forces black base with white star particles and glow box-shadow
- Meteors: horizontal streaks with glow, animate diagonally like shooting stars
- Beams: switched to SVG lines radiating from a central vanishing point
- Auto-adapt text/nav colours for forced-dark (shader, sparkles) and forced-light (aurora)
- LIBRARY_STYLE_OPTIONS: 8 Aceternity background options, default changed to gradient

Made-with: Cursor
2026-03-05 21:08:14 -08:00
e79c2fe5c5 Upgrade marketing scaffolds: real CSS animations, 18 DaisyUI themes, Aceternity accents
- MarketingAceternity: animated gradient blobs (mix-blend-mode hard-light), meteor
  streaks, sparkle dots, CSS marquee testimonials, lamp cone, typewriter cursor,
  bento grid — all using namespaced CSS keyframes
- MarketingDaisy: DaisyUI-style layouts (split hero with mockup, stats hero, step
  guide), testimonials, FAQ accordion, logo strip; full 18-theme palette
- MarketingHeroUI: blur backdrop nav, gradient-mesh/glass/aurora backgrounds,
  metric cards with active-bg tint, avatar stack, glassmorphism cards
- MarketingTailwind: editorial typography, dot-grid/lines backgrounds, terminal
  deploy mockup, checklist features, stats bar, high-contrast CTA
- types.ts: expanded DAISY_THEMES to 18 themes (cyberpunk, halloween, valentine,
  aqua, luxury, night, coffee, nord, dim, sunset); added ACETERNITY_THEMES palette
- index.ts: export ACETERNITY_THEMES, wire aceternity + tailwind-only into THEME_REGISTRY

Made-with: Cursor
2026-03-05 20:55:21 -08:00
b020f73ca7 Simplify right panel: name buttons for library, labels above options, lock at top
Made-with: Cursor
2026-03-05 20:34:51 -08:00
2d8fbbbd81 Move design configurables to right panel, hide shell right panel on design tab
- ProjectShell right panel (Discovery/Captured) hidden when on design tab
- SurfaceSection restructured: scaffold preview center, controls right panel (280px)
- Library cards in 2-col grid, configurator and color picker scroll in right panel
- Main content area uses full height without extra padding

Made-with: Cursor
2026-03-05 20:28:24 -08:00
9c8e1a5f34 Add live design configurator for marketing surface
Users can now compose their marketing site by selecting:
- Mode (dark/light), Background style (gradient/beams/meteors/etc.),
  Nav style, Hero header layout, which Sections appear, and Font.

All 4 marketing scaffolds (DaisyUI, HeroUI, Aceternity, Tailwind)
respond live to config changes. Library capability cards + style
options data defined per library. Aceternity shows actual
background effects (beams, meteors, sparkles, wavy, dot-grid).

Made-with: Cursor
2026-03-05 20:15:59 -08:00
a980354da6 Replace flat library buttons with capability cards on design page
Each library option now shows: best-for summary, 3 key highlights,
capability tags, Templates badge, and Dark-first badge. All surface
themes updated with richer metadata. Marketing surface updated with
full highlights for DaisyUI/HeroUI/Aceternity/Tailwind.

Made-with: Cursor
2026-03-05 20:01:31 -08:00
57c283796f refactor(design): modularize scaffolds into per-surface files + unique admin
- Deleted monolithic design-scaffolds.tsx (1154 lines, 72KB)
- New folder: components/design-scaffolds/
  - types.ts       — ThemeColor interface + all theme palettes
  - web-app.tsx    — SaaS app: Dashboard / Users / Settings with AppShell
  - marketing.tsx  — Landing page: hero, features, pricing, CTA
  - admin.tsx      — NEW unique admin: System health (servers/CPU/mem/errors),
                     Moderation (user table + audit log + ban/impersonate),
                     Config (API keys, feature flags, webhooks)
  - mobile.tsx     — Phone frame previews: NativeWind / Gluestack
  - email.tsx      — React Email welcome template preview
  - docs.tsx       — Nextra + shadcn docs previews
  - index.ts       — SCAFFOLD_REGISTRY + THEME_REGISTRY (only import needed)
- Adding a new surface = create one file + add 2 lines to index.ts

Made-with: Cursor
2026-03-05 19:54:38 -08:00
d30af447da feat(chat): render architecture generation button from NEXT_STEP marker
- Detect [[NEXT_STEP:{...}]] marker in Atlas messages alongside existing
  [[PHASE_COMPLETE:{...}]] - extracted via extractMarkers()
- When action=generate_architecture, render an inline action card in
  the chat: button calls POST /architecture, shows spinner while
  generating, then success state with direct link to Build tab
- Add spin keyframe; thread workspace param through MessageRow

Made-with: Cursor
2026-03-03 21:18:34 -08:00
a3aa5e4208 fix(arch+design): wire architecture and design together
- Architecture route now uses /generate endpoint (no Atlas session
  overhead, no conflicting system prompt) for clean JSON generation
- Design page fetches saved architecture on load and maps designSurfaces
  to known surface IDs via fuzzy match; AI-suggested surfaces are
  pre-selected in the picker with an "AI" badge and explanatory note

Made-with: Cursor
2026-03-03 21:11:27 -08:00
bedd7d3470 feat(build): AI architecture recommendation with review + confirm flow
- New /api/projects/[projectId]/architecture (GET/POST/PATCH) — reads PRD
  + phases, calls AI to generate structured monorepo architecture JSON,
  persists to fs_projects.data.architecture; PATCH sets confirmed flag
- Rebuilt Build tab to show the AI-generated recommendation: expandable
  app cards (tech stack, key screens), shared packages, infrastructure,
  integrations, and risk notes; confirm button + "adjustable later" note

Made-with: Cursor
2026-03-03 21:02:06 -08:00
156232062d Fix: always show AtlasChat on overview (not OrchestratorChat after PRD save)
Made-with: Cursor
2026-03-03 20:45:26 -08:00
9e4450e400 Fix: strip tool messages from preloaded history (Gemini ordering error) + cast PRD param to text
Made-with: Cursor
2026-03-03 20:36:41 -08:00
3896eb671c feat: PWA support + mobile-responsive layout + QR code to open Atlas on phone
Made-with: Cursor
2026-03-02 20:56:20 -08:00
585343968e feat: live phase completion in right panel + saved phase data in PRD page
Made-with: Cursor
2026-03-02 20:44:36 -08:00
5bfbe86541 feat: inline Save Phase button in Atlas chat when phase is complete
Made-with: Cursor
2026-03-02 20:24:08 -08:00
c8d8deb2cc Fix AtlasChat crash: guard renderContent and message render against null content
Made-with: Cursor
2026-03-02 19:57:59 -08:00
7732b5fbea Fix settings layout: replace deleted components with VIBNSidebar
Made-with: Cursor
2026-03-02 19:37:12 -08:00
33ec7b787f Major cleanup: remove all dead pages and components
Components deleted (~27 files):
- components/ai/ (9 files — collector, extraction, vision, phase sidebar)
- components/assistant-ui/ (thread.tsx, markdown-text.tsx)
- components/mission/, sidebar/, extension/, icons/
- layout/app-shell, left-rail, right-panel, connect-sources-modal,
  mcp-connect-modal, page-header, page-template, project-sidebar,
  workspace-left-rail, PAGE_TEMPLATE_GUIDE
- chatgpt-import-card, mcp-connection-card, mcp-playground

Project pages deleted (~20 dirs):
- analytics, api-map, architecture, associate-sessions, audit,
  audit-test, automation, code, context, design-old, docs, features,
  getting-started, journey, market, mission, money, plan, product,
  progress, sandbox, sessions, tech, timeline-plan

Workspace routes deleted (~12 dirs):
- connections, costs, debug-projects, debug-sessions, keys, mcp,
  new-project, projects/new, test-api-key, test-auth, test-sessions, users

Remaining: 5 components, 2 layout files, 8 project tabs, 3 workspace routes
Made-with: Cursor
2026-03-02 19:22:13 -08:00
ecdeee9f1a Render modal via portal to body for true viewport centering
Made-with: Cursor
2026-03-02 19:13:35 -08:00
db21737f50 Add project type selection step to creation modal
Made-with: Cursor
2026-03-02 19:09:35 -08:00
7602d81120 Simplify project creation: name → create → redirect
- Remove GitHub step entirely; single input + Next button
- Creates project immediately, redirects to /overview on success
- Rewritten in Stackless inline style (no shadcn Dialog/Button/Input)

Made-with: Cursor
2026-03-02 19:05:50 -08:00
1ce4ad4c8b Fix sidebar toggle layout in collapsed mode
Made-with: Cursor
2026-03-02 17:16:56 -08:00
3e0be782c4 Make sidebar collapse toggle more prominent
Made-with: Cursor
2026-03-02 17:13:54 -08:00
11d6f14645 Fix Atlas chat duplicate messages; add Reset button
- Add cleanup flag to useEffect to prevent state updates after unmount,
  eliminating the race condition on rapid navigation
- Add handleReset: calls DELETE endpoint, clears state, re-triggers greeting
- Add subtle "Reset" button (top-right of message area) so user can wipe
  polluted history and start fresh

Made-with: Cursor
2026-03-02 17:02:13 -08:00
d3a5655948 Add collapsible sidebar with icon-only skinny mode
- Toggle with ‹‹/›› button; state persists in localStorage
- Collapsed (56px): icons only, nav labels as native title tooltips,
  project list shows status dots only, user avatar centered
- Smooth 200ms cubic-bezier width transition; no flash on initial load
- Expanded (220px): unchanged visual layout

Made-with: Cursor
2026-03-02 16:57:39 -08:00
0146ae7df6 Persist Atlas chat history; fix re-greeting on refresh
- GET /api/projects/[id]/atlas-chat returns stored user+assistant messages
- POST handles __atlas_init__ trigger: runs once when no history exists,
  not stored as a user turn so Atlas intro appears cleanly
- Rewrite AtlasChat.tsx: fully self-contained component with own message
  state; loads history from DB on mount, only greets on first open
- Remove assistant-ui runtime dependency for message persistence
- Add Vision & Success Metrics, Integrations & Dependencies, Open Questions
  to PRD section tracker (now 12 sections matching the PDF)

Made-with: Cursor
2026-03-02 16:55:10 -08:00
9fc643f9b6 Restyle design page to match Stackless aesthetic
- Replace all Tailwind/shadcn classes with inline styles
- Use warm beige palette, Outfit/Newsreader fonts, Stackless card pattern
- Replace Lucide icons with simple Unicode glyphs
- Surface picker and left nav match the sidebar/activity visual language
- Controls bar (library tabs, swatches, lock-in) restyled to match

Made-with: Cursor
2026-03-02 16:44:37 -08:00
7f452c0420 Add Launch, Grow, Insights tabs; rename Deploy → Launch
- Rename Deploy tab label to Launch in ProjectShell
- Add Grow and Insights placeholder pages with Stackless styling

Made-with: Cursor
2026-03-02 16:39:13 -08:00
d60d300a64 Complete Stackless parity: Activity, Deploy, Settings, header desc
- Add project description line to project header (from productVision)
- Sidebar: add Activity nav item (Projects / Activity / Settings)
- New Activity page: timeline feed with type filters (Atlas/Builds/Deploys/You)
- New Activity layout using VIBNSidebar
- Rewrite Deploy tab: Project URLs, Custom Domain, Env Vars, Deploy History
  — fully Stackless style, real data from project API, no more MOCK_PROJECT
- Rewrite Project Settings tab: remove all Firebase refs (db, auth, Firestore)
  — General (name/description), Repo link, Collaborators, Export JSON/PDF,
  — Danger Zone with double-confirm delete
  — uses /api/projects/[id] PATCH for saves

Made-with: Cursor
2026-03-02 16:33:09 -08:00
59c8ec2e02 Switch to Outfit/Newsreader/IBM Plex Mono, add Stackless global polish
- Replace Geist with Outfit (sans), Newsreader (serif), IBM Plex Mono
  loaded via next/font for optimal performance and no layout shift
- Wire --font-sans/serif/mono CSS variables to new fonts
- body/button/input now render in Outfit by default
- Add Stackless global polish: 4px thin scrollbars (#d0ccc4 thumb),
  black ::selection, input placeholder color #b5b0a6

Made-with: Cursor
2026-03-02 16:21:20 -08:00
9858a7fa15 Apply Stackless chat design to Atlas thread
- Remove card container (no more rounded-2xl, ring, 600px height)
- Chat fills the full layout space naturally
- Avatars: 28x28 rounded-7 squares (black for Atlas, warm gray for user)
- Both sides use same avatar+label layout (no right-aligned bubbles)
- Sender labels: tiny uppercase ATLAS / YOU above each message
- Input bar: white pill with border, Send button, Stop for streaming
- User initial pulled from session (name or email first letter)

Made-with: Cursor
2026-03-02 16:15:25 -08:00
94bb9dbeb4 Add Stackless right panel to project shell
Shows Discovery phase tracker (Big Picture → Risks), Captured data
from Atlas, and Project Info (created, last active, features).
Data flows from DB via layout server component.

Made-with: Cursor
2026-03-02 16:11:58 -08:00
aaa3f51592 Adopt Stackless UI: warm palette, sidebar, project tab bar with Design tab
- Add Google Fonts (Newsreader/Outfit/IBM Plex Mono) + warm beige CSS palette
- New VIBNSidebar: Stackless-style 220px sidebar with project list + user footer
- New ProjectShell: project header with name/status/progress% + tab bar
- Tabs: Atlas → PRD → Design → Build → Deploy → Settings
- New /prd page: section-by-section progress view
- New /build page: locked until PRD complete
- Projects list page: Stackless-style row layout
- Simplify overview page to just render AtlasChat

Made-with: Cursor
2026-03-02 16:01:33 -08:00
7ba3b9563e refactor: move all design controls below scaffold render
Theme swatches removed from inside scaffold components. Theme state
lifted to SurfaceSection which passes themeColor down as a prop.

Controls bar below the scaffold now has three rows:
  1. Library tabs (shadcn / Mantine / HeroUI / Tremor etc.)
  2. Color theme swatches — only shown when the active library has
     theme variants (shadcn: 8, Mantine: 6, HeroUI: 5, Tremor: 5,
     DaisyUI: 12, HeroUI marketing: 6)
  3. Description + tags + Docs link + Lock in button

Scaffold renders cleanly with no UI chrome inside it.

Made-with: Cursor
2026-03-02 14:06:53 -08:00
16766f587d feat: full palette themes for DaisyUI and HeroUI marketing scaffolds
DaisyUI scaffold now has 12 named themes (Dark, Light, Cupcake, Bee,
Synthwave, Cyberpunk, Retro, Dracula, Night, Forest, Coffee, Winter).
Each theme changes the page background, card bg, text, borders, and
primary color — swatch shows a split circle of bg+primary so you can
preview the full palette at a glance.

HeroUI marketing scaffold has 6 prebuilt themes (Purple, Blue, Teal,
Rose, Dark, Modern) matching heroui.com/themes.

ThemeColor type now supports optional bg/cardBg/textColor/borderColor/
mutedText fields for full-page dark/light palette overrides.

Made-with: Cursor
2026-03-02 13:58:02 -08:00
817fe3a1a4 refactor: move design controls below scaffold preview
Made-with: Cursor
2026-03-02 13:50:15 -08:00
b3462a31a7 feat: color theme swatches inside web app scaffolds
Each web app scaffold (shadcn, Mantine, HeroUI, Tremor) now shows
a row of color swatches in the header. Clicking a swatch updates the
primary color across the entire scaffold — sidebar active state,
buttons, bar chart, badges, toggles, and status indicators all update
live. shadcn has 8 themes (Neutral/Blue/Green/Orange/Red/Rose/Violet/
Yellow), Mantine has 6, HeroUI has 5, Tremor has 5.

Made-with: Cursor
2026-03-02 13:28:58 -08:00
086047d177 feat: interactive page nav inside web app scaffolds
Each web app scaffold (shadcn, Mantine, HeroUI, Tremor) now has
clickable sidebar nav between Dashboard, Users, and Settings pages.
Dashboard shows stat cards + bar chart + activity feed. Users shows
a full data table with roles, status badges, and invite controls.
Settings shows form inputs and notification toggles — all styled to
each library's visual language.

Made-with: Cursor
2026-03-02 13:22:24 -08:00
54248887f1 feat: design page scaffold previews with library toggle
Each surface now shows a realistic scaffold preview in a browser chrome
frame. Tab bar at the top toggles between library options (shadcn,
DaisyUI, HeroUI, Mantine, Aceternity, etc.) — the scaffold updates
instantly to show that library's visual language. Lock in confirms
the choice. Scaffolds cover all 6 surfaces × their library options.

Made-with: Cursor
2026-03-02 12:47:10 -08:00
7cf4f2ef78 feat: design page - left nav for surface selection, main area for theme picker
Made-with: Cursor
2026-03-02 12:36:40 -08:00
ea54440be7 refactor: simplify project nav to AI Chat (overview) + Design only
- AI Chat nav item now routes to /overview instead of /v_ai_chat
- Removed Plan, Docs, Tech, Journey nav items
- Deleted old v_ai_chat page
- Cleaned up unused imports and route detection logic

Made-with: Cursor
2026-03-02 12:29:32 -08:00
7be66f60b7 fix: qualify table references in design-surfaces SQL to resolve ambiguous column error
Made-with: Cursor
2026-03-01 21:30:12 -08:00
62731af91f feat: design surfaces page with two-phase theme picker
Phase 1: user picks which surfaces their product needs (Web App,
Marketing Site, Admin, Mobile, Email, Docs). Phase 2: per-surface
horizontal card gallery with mini visual previews of each UI library.
Lock in confirms the choice; locked themes are saved to DB and shown
to the AI coder. Surfaces and themes stored in fs_projects.data.

Made-with: Cursor
2026-03-01 21:14:20 -08:00
287bc96fac feat: design packages page — pick UI library per Turborepo app
Replaces the old design page with a per-app package selector. Fetches
real apps/ from the project's Gitea repo and lets users assign a UI
library (shadcn, DaisyUI, HeroUI, Mantine, Headless UI, or Tailwind
only) independently per app. Selections saved to fs_projects.data.designPackages.

Made-with: Cursor
2026-03-01 20:33:39 -08:00
c842a4b75b fix: clean up chat UI layout and align theme to neutral white
- Replace beige background with clean neutral white (matches Grok aesthetic)
- Remove hardcoded hex colors in thread.tsx - use CSS variables throughout
- Remove scroll-to-bottom button that showed incorrectly after auto-send
- Chat container now integrates visually with the page instead of floating

Made-with: Cursor
2026-03-01 20:21:39 -08:00
a2bde95222 feat: apply Grok-style minimalist UI to Atlas chat
Clean pill composer with inverted send button, plain assistant messages
(no bubble), centered welcome+composer when thread is empty, and Grok
color palette (#fdfdfd/#141414 backgrounds, ring borders).

Made-with: Cursor
2026-03-01 20:14:15 -08:00
86504c4c55 fix: ThreadPrimitive.FollowupSuggestions → Suggestions, autoSend → send
Made-with: Cursor
2026-03-01 20:02:57 -08:00
9bec2e9b17 feat: replace AtlasChat with assistant-ui Thread component
- Install @assistant-ui/react and @assistant-ui/react-markdown
- components/assistant-ui/thread.tsx — full Thread UI with primitives
- components/assistant-ui/markdown-text.tsx — GFM markdown renderer
- AtlasChat.tsx — useLocalRuntime adapter calling existing atlas-chat API

Gives proper markdown rendering, branch switching, copy/retry actions,
cancel button during streaming, and a polished thread experience.

Made-with: Cursor
2026-03-01 16:39:35 -08:00
296324f424 refactor: simplify overview page — header above chat, remove widget grid
Move project name/badges/Refresh/Open IDE above the agent chat panel.
Remove stats, code repo, deployment, PRs, issues, resources sections.

Made-with: Cursor
2026-03-01 16:01:35 -08:00
26a11412b5 feat: add Atlas discovery chat UI and API route
- components/AtlasChat.tsx — conversational PRD discovery UI (violet theme)
- app/api/projects/[projectId]/atlas-chat/route.ts — proxy + DB persistence
- overview/page.tsx — show Atlas for new projects, Orchestrator once PRD done

Made-with: Cursor
2026-03-01 15:56:32 -08:00
35675b7d86 fix: stop prisma from dropping custom tables on every deploy
entrypoint.sh: removed --accept-data-loss from prisma db push.
That flag was silently dropping fs_users, fs_projects etc. on every
container restart, wiping all user/project data. Made the push
non-fatal so a schema mismatch doesn't block startup.

create/route.ts: fixed same broken ON CONFLICT expression as
authOptions.ts — replaced with explicit SELECT + INSERT/UPDATE
to reliably upsert fs_users before inserting the project.

Made-with: Cursor
2026-02-27 19:15:55 -08:00
8c3486dd58 feat: persistent AI memory — chat history + knowledge store
agent-chat/route.ts:
- Loads conversation history from chat_conversations before each turn
- Passes history + knowledge context to agent runner
- Saves returned history back to chat_conversations after each turn
- Saves AI-generated memory updates to fs_knowledge_items

knowledge/route.ts (new):
- GET  /api/projects/[id]/knowledge — list all knowledge items
- POST /api/projects/[id]/knowledge — add/update item by key
- DELETE /api/projects/[id]/knowledge?id=xxx — remove item

OrchestratorChat.tsx:
- Added "Saved to memory" label for save_memory tool calls

Made-with: Cursor
2026-02-27 18:55:41 -08:00
a893d95387 fix: reliable fs_users upsert on sign-in
ON CONFLICT expression matching was silently failing due to a mismatch
between the query expression and the index definition (::text cast).
Replaced with an explicit SELECT-then-INSERT-or-UPDATE pattern.

Made-with: Cursor
2026-02-27 18:24:47 -08:00
b2b3424b05 fix: clean up orchestrator chat UX
- Tool call names now show human-readable labels ("Dispatched agent"
  instead of "spawn_agent"), deduped if called multiple times
- Model label only shown when a real value is returned; "unknown"
  and null are suppressed; model names shortened (GLM-5, Gemini)

Made-with: Cursor
2026-02-27 18:15:50 -08:00
fe89087cc5 fix: correct auth import path in agent-chat route
Was importing from @/lib/auth (which doesn't exist); correct path
is @/lib/auth/authOptions — this caused the Turbopack build to fail.

Made-with: Cursor
2026-02-27 18:09:22 -08:00
8d95a74cc6 add orchestrator chat to project overview
- OrchestratorChat component with Lovable-style UI (suggestion chips, reasoning panel, tool call badges)
- /api/projects/[projectId]/agent-chat proxy route to agent runner
- Injects project context (repo, vision, deployment URL) into session
- AGENT_RUNNER_URL wired to agents.vibnai.com

Made-with: Cursor
2026-02-27 18:06:02 -08:00
c9ef2379ec fix: upsert fs_users before inserting fs_projects to satisfy FK constraint
Made-with: Cursor
2026-02-27 13:36:25 -08:00
ef7a88e913 migrate: replace Firebase with PostgreSQL across core routes
- chat-context.ts: session history now from fs_sessions
- /api/sessions: reads from fs_sessions (NextAuth session auth)
- /api/github/connect: NextAuth session + stores in fs_users.data
- /api/user/api-key: NextAuth session + stores in fs_users.data
- /api/projects/[id]/vision: PATCH to fs_projects JSONB
- /api/projects/[id]/knowledge/items: reads from fs_knowledge_items
- /api/projects/[id]/knowledge/import-ai-chat: uses pg createKnowledgeItem
- lib/server/knowledge.ts: fully rewritten to use PostgreSQL
- entrypoint.sh: add fs_knowledge_items and chat_conversations tables

Made-with: Cursor
2026-02-27 13:25:38 -08:00
3ce10dc45b fix: remove SSL for internal Docker DB connections — fixes 500 on projects API
Made-with: Cursor
2026-02-27 13:01:57 -08:00
0625943cc1 fix: remove SSL from internal DB connection in entrypoint
Made-with: Cursor
2026-02-27 12:51:50 -08:00
cb0ece541f fix: ensure fs_ app tables created on every startup via node/pg
Made-with: Cursor
2026-02-27 12:48:02 -08:00
d8ead667d0 fix: create fs_user on sign-in, fix projects fetch
Made-with: Cursor
2026-02-27 12:39:25 -08:00
17056ea00c fix: restore auth fixes — next-auth prisma adapter, serverExternalPackages, prisma db push on start
Made-with: Cursor
2026-02-27 12:30:52 -08:00
184 changed files with 18204 additions and 22530 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets.
# --- Postgres (Coolify internal service DNS, same stack as this app) ---
# Example: postgresql://USER:PASS@<coolify-service-uuid>:5432/vibn
DATABASE_URL=
POSTGRES_URL=
# --- Public URL of this Next app (OAuth callbacks, runner callbacks) ---
NEXTAUTH_URL=https://vibnai.com
NEXTAUTH_SECRET=
# --- vibn-agent-runner (same Docker network: http://<service-name>:3333 — or public https://agents.vibnai.com) ---
AGENT_RUNNER_URL=http://localhost:3333
# --- Shared secret: must match runner. Required for PATCH session + POST /events ingest ---
AGENT_RUNNER_SECRET=
# --- Optional: one-shot DDL via POST /api/admin/migrate ---
# ADMIN_MIGRATE_SECRET=
# --- Google OAuth / Gemini (see .google.env locally) ---
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -8,7 +8,7 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps --ignore-scripts
RUN npm install --legacy-peer-deps --ignore-scripts
FROM base AS builder
WORKDIR /app
@@ -41,8 +41,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/@next-auth ./node_modules/@next-auth
COPY --from=builder /app/prisma ./prisma
# Scaffold templates are read at runtime via fs — must be in the runner image
COPY --from=builder /app/lib/scaffold ./lib/scaffold
# Copy and set up entrypoint
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh

View File

@@ -18,7 +18,7 @@ export default function FeaturesPage() {
return (
<div className="container py-8 md:py-12 lg:py-24">
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
<h1 className="text-4xl font-extrabold leading-tight tracking-tighter md:text-6xl lg:leading-[1.1]">
<h1 className="font-serif text-4xl font-bold leading-tight tracking-tight md:text-6xl lg:leading-[1.1]">
Powerful Features for AI Developers
</h1>
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
@@ -30,7 +30,7 @@ export default function FeaturesPage() {
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<Code2 className="h-12 w-12 text-blue-600" />
<Code2 className="h-12 w-12 text-primary" />
<CardTitle>Automatic Session Tracking</CardTitle>
<CardDescription>
Every coding session is automatically captured with zero configuration.
@@ -48,7 +48,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Brain className="h-12 w-12 text-purple-600" />
<Brain className="h-12 w-12 text-primary" />
<CardTitle>AI Usage Analytics</CardTitle>
<CardDescription>
Deep insights into how you and your team use AI tools.
@@ -66,7 +66,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<DollarSign className="h-12 w-12 text-green-600" />
<DollarSign className="h-12 w-12 text-primary" />
<CardTitle>Cost Tracking</CardTitle>
<CardDescription>
Real-time cost monitoring for all your AI services.
@@ -84,7 +84,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Clock className="h-12 w-12 text-orange-600" />
<Clock className="h-12 w-12 text-primary" />
<CardTitle>Productivity Metrics</CardTitle>
<CardDescription>
Track your velocity and identify productivity patterns.
@@ -102,7 +102,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Github className="h-12 w-12 text-gray-600" />
<Github className="h-12 w-12 text-primary" />
<CardTitle>GitHub Integration</CardTitle>
<CardDescription>
Connect your repositories for comprehensive code analysis.
@@ -120,7 +120,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Sparkles className="h-12 w-12 text-pink-600" />
<Sparkles className="h-12 w-12 text-primary" />
<CardTitle>Smart Summaries</CardTitle>
<CardDescription>
AI-powered summaries of your work and progress.
@@ -138,7 +138,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Users className="h-12 w-12 text-cyan-600" />
<Users className="h-12 w-12 text-primary" />
<CardTitle>Team Collaboration</CardTitle>
<CardDescription>
Built for teams working with AI tools together.
@@ -156,7 +156,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<FileCode className="h-12 w-12 text-indigo-600" />
<FileCode className="h-12 w-12 text-primary" />
<CardTitle>Code Quality Tracking</CardTitle>
<CardDescription>
Monitor code quality and AI-generated code effectiveness.
@@ -174,7 +174,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<TrendingUp className="h-12 w-12 text-emerald-600" />
<TrendingUp className="h-12 w-12 text-primary" />
<CardTitle>Trend Analysis</CardTitle>
<CardDescription>
Understand long-term patterns in your development process.
@@ -192,7 +192,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Shield className="h-12 w-12 text-red-600" />
<Shield className="h-12 w-12 text-primary" />
<CardTitle>Privacy & Security</CardTitle>
<CardDescription>
Your code and data stay private and secure.
@@ -210,7 +210,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<Zap className="h-12 w-12 text-yellow-600" />
<Zap className="h-12 w-12 text-primary" />
<CardTitle>Real-Time Insights</CardTitle>
<CardDescription>
Get instant feedback as you code.
@@ -228,7 +228,7 @@ export default function FeaturesPage() {
<Card>
<CardHeader>
<BarChart3 className="h-12 w-12 text-violet-600" />
<BarChart3 className="h-12 w-12 text-primary" />
<CardTitle>Custom Reports</CardTitle>
<CardDescription>
Create custom reports tailored to your needs.

View File

@@ -38,7 +38,7 @@ export default function MarketingLayout({
alt="Vib'n"
className="h-8 w-8"
/>
<span className="text-xl font-bold">Vib&apos;n</span>
<span className="font-serif text-xl font-bold tracking-tight">Vib&apos;n</span>
</Link>
</div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">

View File

@@ -0,0 +1,20 @@
"use client";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { useParams } from "next/navigation";
import { ReactNode } from "react";
import { Toaster } from "sonner";
export default function ActivityLayout({ children }: { children: ReactNode }) {
const params = useParams();
const workspace = params.workspace as string;
return (
<>
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
<VIBNSidebar workspace={workspace} />
<main style={{ flex: 1, overflow: "auto" }}>{children}</main>
</div>
<Toaster position="top-center" />
</>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
interface ActivityItem {
id: string;
projectId: string;
projectName: string;
action: string;
type: "atlas" | "build" | "deploy" | "user";
createdAt: string;
}
function timeAgo(dateStr: string): string {
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 date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function typeColor(t: string) {
return t === "atlas" ? "#1a1a1a" : t === "build" ? "#3d5afe" : t === "deploy" ? "#2e7d32" : "#8a8478";
}
const FILTERS = [
{ id: "all", label: "All" },
{ id: "atlas", label: "Vibn" },
{ id: "build", label: "Builds" },
{ id: "deploy", label: "Deploys" },
{ id: "user", label: "You" },
];
export default function ActivityPage() {
const params = useParams();
const workspace = params.workspace as string;
const [filter, setFilter] = useState("all");
const [items, setItems] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/activity")
.then((r) => r.json())
.then((d) => setItems(d.items ?? []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const filtered = filter === "all" ? items : items.filter((a) => a.type === filter);
return (
<div
className="vibn-enter"
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<h1 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
}}>
Activity
</h1>
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 28 }}>
Everything happening across your projects
</p>
{/* Filter pills */}
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
{FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
style={{
padding: "6px 14px", borderRadius: 6, border: "none",
background: filter === f.id ? "#1a1a1a" : "#fff",
color: filter === f.id ? "#fff" : "#6b6560",
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
{f.label}
</button>
))}
</div>
{loading && (
<p style={{ fontSize: "0.82rem", color: "#b5b0a6" }}>Loading</p>
)}
{/* Timeline */}
{!loading && filtered.length === 0 && (
<p style={{ fontSize: "0.82rem", color: "#b5b0a6" }}>No activity yet.</p>
)}
{!loading && filtered.length > 0 && (
<div style={{ position: "relative", paddingLeft: 24 }}>
{/* Vertical line */}
<div style={{
position: "absolute", left: 8, top: 8, bottom: 8,
width: 1, background: "#e8e4dc",
}} />
{filtered.map((item, i) => (
<div
key={item.id}
className="vibn-enter"
style={{
display: "flex", gap: 14, marginBottom: 4,
padding: "12px 16px", borderRadius: 8,
transition: "background 0.12s", position: "relative",
animationDelay: `${i * 0.03}s`,
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "#fff")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{/* Timeline dot */}
<div style={{
position: "absolute", left: -20, top: 18,
width: 9, height: 9, borderRadius: "50%",
background: typeColor(item.type),
border: "2px solid #f6f4f0",
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}>
<Link
href={`/${workspace}/project/${item.projectId}/overview`}
style={{
fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a",
textDecoration: "none",
}}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = "underline")}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")}
>
{item.projectName}
</Link>
<span style={{ fontSize: "0.68rem", color: "#b5b0a6" }}>·</span>
<span style={{ fontSize: "0.72rem", color: "#b5b0a6" }}>{timeAgo(item.createdAt)}</span>
</div>
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.5 }}>
{item.action}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,33 +0,0 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
import { Toaster } from "sonner";
export default function ConnectionsLayout({
children,
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("connections");
return (
<>
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
<Toaster position="top-center" />
</>
);
}

View File

@@ -1,360 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Github, CheckCircle2, Download, Copy, Check, Eye, EyeOff } from "lucide-react";
import { CursorIcon } from "@/components/icons/custom-icons";
import { toast } from "sonner";
import { auth } from "@/lib/firebase/config";
import type { User } from "firebase/auth";
import { MCPConnectionCard } from "@/components/mcp-connection-card";
import { ChatGPTImportCard } from "@/components/chatgpt-import-card";
export default function ConnectionsPage() {
const [githubConnected, setGithubConnected] = useState(false);
const [extensionInstalled] = useState(false); // Future use: track extension installation
const [copiedApiKey, setCopiedApiKey] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [apiKey, setApiKey] = useState<string | null>(null);
const [loadingApiKey, setLoadingApiKey] = useState(true);
const [apiUrl, setApiUrl] = useState('https://vibnai.com');
// Set API URL on client side to avoid hydration mismatch
useEffect(() => {
if (typeof window !== 'undefined') {
setApiUrl(window.location.origin);
}
}, []);
// Fetch API key on mount
useEffect(() => {
async function fetchApiKey(user: User) {
try {
console.log('[Client] Getting ID token for user:', user.uid);
const token = await user.getIdToken();
console.log('[Client] Token received, length:', token.length);
const response = await fetch('/api/user/api-key', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
console.log('[Client] Response status:', response.status);
if (response.ok) {
const data = await response.json();
console.log('[Client] API key received');
setApiKey(data.apiKey);
} else {
const errorData = await response.json();
console.error('[Client] Failed to fetch API key:', response.status, errorData);
}
} catch (error) {
console.error('[Client] Error fetching API key:', error);
} finally {
setLoadingApiKey(false);
}
}
// Listen for auth state changes
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
fetchApiKey(user);
} else {
setLoadingApiKey(false);
}
});
return () => unsubscribe();
}, []);
const handleConnectGitHub = async () => {
// TODO: Implement GitHub OAuth flow
toast.success("GitHub connected successfully!");
setGithubConnected(true);
};
const handleInstallExtension = () => {
// Link to Cursor Monitor extension (update with actual marketplace URL when published)
window.open("https://marketplace.visualstudio.com/items?itemName=cursor-monitor", "_blank");
};
const handleCopyApiKey = () => {
if (apiKey) {
navigator.clipboard.writeText(apiKey);
setCopiedApiKey(true);
toast.success("API key copied to clipboard!");
setTimeout(() => setCopiedApiKey(false), 2000);
}
};
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-4xl font-bold mb-2">Connect Your Tools</h1>
<p className="text-muted-foreground text-lg">
Set up your development tools to unlock the full power of Vib&apos;n
</p>
</div>
{/* Connection Cards */}
<div className="space-y-6">
{/* Cursor Extension */}
<Card className={extensionInstalled ? "border-green-500/50 bg-green-500/5" : ""}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
<CursorIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle>Cursor Monitor Extension</CardTitle>
{extensionInstalled && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
</div>
<CardDescription>
Automatically track your coding sessions, AI usage, and costs
</CardDescription>
</div>
</div>
{!extensionInstalled ? (
<Button onClick={handleInstallExtension}>
<Download className="h-4 w-4 mr-2" />
Get Extension
</Button>
) : (
<Button variant="outline" disabled>
<CheckCircle2 className="h-4 w-4 mr-2" />
Installed
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2 text-sm text-muted-foreground">
<p className="font-medium text-foreground">What it does:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Tracks your coding sessions in real-time</li>
<li>Monitors AI model usage and token consumption</li>
<li>Logs file changes and conversation history</li>
<li>Calculates costs automatically</li>
</ul>
</div>
{!extensionInstalled && (
<>
<div className="rounded-lg bg-muted p-4 space-y-2">
<p className="text-sm font-medium">Installation Steps:</p>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground ml-2">
<li>Install the Cursor Monitor extension from the marketplace</li>
<li>Restart Cursor to activate the extension</li>
<li>Configure your API key (see instructions below)</li>
<li>Start coding - sessions will be tracked automatically!</li>
</ol>
</div>
<div className="rounded-lg bg-primary/10 border border-primary/20 p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Your API Key</p>
{!loadingApiKey && apiKey && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCopyApiKey}
>
{copiedApiKey ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
{loadingApiKey ? (
<div className="text-sm text-muted-foreground">Loading...</div>
) : apiKey ? (
<>
<Input
type={showApiKey ? "text" : "password"}
value={apiKey}
readOnly
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Add this key to your extension settings to connect it to your Vibn account.
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
Sign in to generate your API key
</p>
)}
</div>
<div className="rounded-lg bg-muted p-4 space-y-2">
<p className="text-sm font-medium">Configure Cursor Monitor Extension:</p>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground ml-2">
<li>Open Cursor Settings (Cmd/Ctrl + ,)</li>
<li>Search for &quot;Cursor Monitor&quot;</li>
<li>Find &quot;Cursor Monitor: Vibn Api Key&quot;</li>
<li>Paste your API key (from above)</li>
<li>Verify &quot;Cursor Monitor: Vibn Api Url&quot; is set to: <code className="text-xs bg-background px-1 py-0.5 rounded">{apiUrl}/api</code></li>
<li>Make sure &quot;Cursor Monitor: Vibn Enabled&quot; is checked</li>
<li>Reload Cursor to start tracking</li>
</ol>
</div>
</>
)}
{extensionInstalled && (
<div className="rounded-lg bg-green-500/10 border border-green-500/20 p-4">
<p className="text-sm text-green-700 dark:text-green-400">
Extension is installed and tracking your sessions
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* GitHub Connection */}
<Card className={githubConnected ? "border-green-500/50 bg-green-500/5" : ""}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Github className="h-6 w-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle>GitHub</CardTitle>
{githubConnected && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
</div>
<CardDescription>
Connect your repositories for automatic analysis
</CardDescription>
</div>
</div>
{!githubConnected ? (
<Button onClick={handleConnectGitHub}>
<Github className="h-4 w-4 mr-2" />
Connect GitHub
</Button>
) : (
<Button variant="outline" disabled>
<CheckCircle2 className="h-4 w-4 mr-2" />
Connected
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2 text-sm text-muted-foreground">
<p className="font-medium text-foreground">What we&apos;ll access:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Read your repository code and structure</li>
<li>Access repository metadata and commit history</li>
<li>Analyze tech stack and dependencies</li>
<li>Identify project architecture patterns</li>
</ul>
</div>
{!githubConnected && (
<div className="rounded-lg bg-muted p-4 space-y-2">
<p className="text-sm font-medium">Why connect GitHub?</p>
<p className="text-sm text-muted-foreground">
Our AI will analyze your codebase to understand your tech stack,
architecture, and features. This helps generate better documentation
and provides smarter insights.
</p>
</div>
)}
{githubConnected && (
<div className="rounded-lg bg-green-500/10 border border-green-500/20 p-4">
<p className="text-sm text-green-700 dark:text-green-400">
GitHub connected - Your repositories are ready for analysis
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* ChatGPT (MCP) Connection */}
<MCPConnectionCard />
{/* ChatGPT Import */}
<ChatGPTImportCard />
</div>
{/* Status Summary */}
{(githubConnected || extensionInstalled) && (
<Card className="bg-primary/5 border-primary/20">
<CardHeader>
<CardTitle className="text-lg">Connection Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<CursorIcon className="h-4 w-4" />
Cursor Extension
</span>
{extensionInstalled ? (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
Installed
</span>
) : (
<span className="text-muted-foreground">Not installed</span>
)}
</div>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Github className="h-4 w-4" />
GitHub
</span>
{githubConnected ? (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
Connected
</span>
) : (
<span className="text-muted-foreground">Not connected</span>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
import { Toaster } from "sonner";
export default function CostsLayout({
children,
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("costs");
return (
<>
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
<Toaster position="top-center" />
</>
);
}

View File

@@ -1,181 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { auth } from '@/lib/firebase/config';
import { toast } from 'sonner';
import { DollarSign, TrendingUp, TrendingDown, Calendar } from 'lucide-react';
import { useParams } from 'next/navigation';
interface CostData {
total: number;
thisMonth: number;
lastMonth: number;
byProject: Array<{
projectId: string;
projectName: string;
cost: number;
}>;
byDate: Array<{
date: string;
cost: number;
}>;
}
export default function CostsPage() {
const params = useParams();
const workspace = params.workspace as string;
const [costs, setCosts] = useState<CostData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadCosts();
}, []);
const loadCosts = async () => {
try {
const user = auth.currentUser;
if (!user) return;
const token = await user.getIdToken();
const response = await fetch('/api/costs', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setCosts(data);
}
} catch (error) {
console.error('Error loading costs:', error);
toast.error('Failed to load cost data');
} finally {
setLoading(false);
}
};
const percentageChange = costs && costs.lastMonth > 0
? ((costs.thisMonth - costs.lastMonth) / costs.lastMonth) * 100
: 0;
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-7xl">
{/* Header */}
<div>
<h1 className="text-4xl font-bold mb-2">Costs</h1>
<p className="text-muted-foreground text-lg">
Track your AI usage costs across all projects
</p>
</div>
{/* Summary Cards */}
{loading ? (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Loading cost data...</p>
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Costs</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${costs?.total.toFixed(2) || '0.00'}</div>
<p className="text-xs text-muted-foreground">All time</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">This Month</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${costs?.thisMonth.toFixed(2) || '0.00'}</div>
<p className="text-xs text-muted-foreground">
{new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">vs Last Month</CardTitle>
{percentageChange >= 0 ? (
<TrendingUp className="h-4 w-4 text-red-500" />
) : (
<TrendingDown className="h-4 w-4 text-green-500" />
)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{percentageChange >= 0 ? '+' : ''}{percentageChange.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
Last month: ${costs?.lastMonth.toFixed(2) || '0.00'}
</p>
</CardContent>
</Card>
</div>
{/* Costs by Project */}
<Card>
<CardHeader>
<CardTitle>Costs by Project</CardTitle>
<CardDescription>Your spending broken down by project</CardDescription>
</CardHeader>
<CardContent>
{costs?.byProject && costs.byProject.length > 0 ? (
<div className="space-y-3">
{costs.byProject.map((project) => (
<div key={project.projectId} className="flex items-center justify-between p-3 rounded-lg border">
<div>
<p className="font-medium">{project.projectName}</p>
<p className="text-sm text-muted-foreground">Project ID: {project.projectId}</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">${project.cost.toFixed(2)}</p>
<p className="text-xs text-muted-foreground">
{((project.cost / (costs.total || 1)) * 100).toFixed(1)}% of total
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground py-8">No project costs yet</p>
)}
</CardContent>
</Card>
{/* Info Card */}
<Card className="border-blue-500/20 bg-blue-500/5">
<CardHeader>
<CardTitle className="text-base">About Cost Tracking</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<strong>📊 Automatic Tracking:</strong> All AI API costs are automatically tracked when you use Vibn features.
</p>
<p>
<strong>💰 Your Keys, Your Costs:</strong> Costs reflect usage of your own API keys - Vibn doesn't add any markup.
</p>
<p>
<strong>📈 Project Attribution:</strong> Costs are attributed to projects based on session metadata.
</p>
</CardContent>
</Card>
</>
)}
</div>
</div>
);
}

View File

@@ -1,239 +0,0 @@
"use client";
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { auth, db } from '@/lib/firebase/config';
import { collection, query, where, getDocs } from 'firebase/firestore';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
interface ProjectDebugInfo {
id: string;
productName: string;
name: string;
slug: string;
userId: string;
workspacePath?: string;
createdAt: any;
updatedAt: any;
}
export default function DebugProjectsPage() {
const [projects, setProjects] = useState<ProjectDebugInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userId, setUserId] = useState<string>('');
const loadProjects = async () => {
setLoading(true);
setError(null);
try {
const user = auth.currentUser;
if (!user) {
setError('Not authenticated');
return;
}
setUserId(user.uid);
const projectsRef = collection(db, 'projects');
const projectsQuery = query(
projectsRef,
where('userId', '==', user.uid)
);
const snapshot = await getDocs(projectsQuery);
const projectsData = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
productName: data.productName || 'N/A',
name: data.name || 'N/A',
slug: data.slug || 'N/A',
userId: data.userId || 'N/A',
workspacePath: data.workspacePath,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
};
});
console.log('DEBUG: All projects from Firebase:', projectsData);
setProjects(projectsData);
} catch (err: any) {
console.error('Error loading projects:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
loadProjects();
} else {
setError('Please sign in');
setLoading(false);
}
});
return () => unsubscribe();
}, []);
return (
<div className="min-h-screen p-8 bg-background">
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">🔍 Projects Debug Page</h1>
<p className="text-muted-foreground mt-2">
View all your projects and their unique IDs from Firebase
</p>
{userId && (
<p className="text-xs text-muted-foreground mt-1">
User ID: <code className="bg-muted px-2 py-1 rounded">{userId}</code>
</p>
)}
</div>
<Button onClick={loadProjects} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{error && (
<Card className="border-red-500">
<CardContent className="pt-6">
<p className="text-red-600">Error: {error}</p>
</CardContent>
</Card>
)}
{loading && !error && (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Loading projects from Firebase...</p>
</CardContent>
</Card>
)}
{!loading && !error && (
<>
<Card>
<CardHeader>
<CardTitle>Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold">{projects.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Unique IDs</p>
<p className="text-2xl font-bold">
{new Set(projects.map(p => p.id)).size}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Duplicate IDs?</p>
<p className={`text-2xl font-bold ${projects.length !== new Set(projects.map(p => p.id)).size ? 'text-red-500' : 'text-green-500'}`}>
{projects.length !== new Set(projects.map(p => p.id)).size ? 'YES ⚠️' : 'NO ✓'}
</p>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<h2 className="text-xl font-semibold">All Projects</h2>
{projects.map((project, index) => (
<Card key={project.id + index}>
<CardHeader>
<CardTitle className="text-lg flex items-center justify-between">
<span>#{index + 1}: {project.productName}</span>
<a
href={`/marks-account/project/${project.id}/overview`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
Open Overview
</a>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Project ID</p>
<code className="block bg-muted px-2 py-1 rounded mt-1 break-all">
{project.id}
</code>
</div>
<div>
<p className="text-muted-foreground">Slug</p>
<code className="block bg-muted px-2 py-1 rounded mt-1 break-all">
{project.slug}
</code>
</div>
<div>
<p className="text-muted-foreground">Product Name</p>
<p className="font-medium mt-1">{project.productName}</p>
</div>
<div>
<p className="text-muted-foreground">Internal Name</p>
<p className="font-medium mt-1">{project.name}</p>
</div>
{project.workspacePath && (
<div className="col-span-2">
<p className="text-muted-foreground">Workspace Path</p>
<code className="block bg-muted px-2 py-1 rounded mt-1 break-all text-xs">
{project.workspacePath}
</code>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(project.id);
alert('Project ID copied to clipboard!');
}}
>
Copy ID
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const url = `/marks-account/project/${project.id}/v_ai_chat`;
window.open(url, '_blank');
}}
>
Open Chat
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
console.log('Full project data:', project);
alert('Check browser console for full data');
}}
>
Log to Console
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,279 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { auth, db } from '@/lib/firebase/config';
import { collection, query, where, getDocs, orderBy, limit } from 'firebase/firestore';
import { RefreshCw, CheckCircle2, AlertCircle, Link as LinkIcon } from 'lucide-react';
interface SessionDebugInfo {
id: string;
projectId?: string;
workspacePath?: string;
workspaceName?: string;
needsProjectAssociation: boolean;
model?: string;
tokensUsed?: number;
cost?: number;
createdAt: any;
}
export default function DebugSessionsPage() {
const [sessions, setSessions] = useState<SessionDebugInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userId, setUserId] = useState<string>('');
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const user = auth.currentUser;
if (!user) {
setError('Not authenticated');
setLoading(false);
return;
}
setUserId(user.uid);
const sessionsRef = collection(db, 'sessions');
// Remove orderBy to avoid index issues - just get recent sessions
const sessionsQuery = query(
sessionsRef,
where('userId', '==', user.uid),
limit(50)
);
const snapshot = await getDocs(sessionsQuery);
const sessionsData = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
projectId: data.projectId || null,
workspacePath: data.workspacePath || null,
workspaceName: data.workspaceName || null,
needsProjectAssociation: data.needsProjectAssociation || false,
model: data.model,
tokensUsed: data.tokensUsed,
cost: data.cost,
createdAt: data.createdAt,
};
});
console.log('DEBUG: All sessions from Firebase:', sessionsData);
setSessions(sessionsData);
} catch (err: any) {
console.error('Error loading sessions:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let mounted = true;
const unsubscribe = auth.onAuthStateChanged((user) => {
if (!mounted) return;
if (user) {
loadSessions();
} else {
setError('Please sign in');
setLoading(false);
}
});
return () => {
mounted = false;
unsubscribe();
};
}, [loadSessions]);
const unassociatedSessions = sessions.filter(s => s.needsProjectAssociation);
const associatedSessions = sessions.filter(s => !s.needsProjectAssociation);
// Group unassociated sessions by workspace path
const sessionsByWorkspace = unassociatedSessions.reduce((acc, session) => {
const path = session.workspacePath || 'No workspace path';
if (!acc[path]) acc[path] = [];
acc[path].push(session);
return acc;
}, {} as Record<string, SessionDebugInfo[]>);
return (
<div className="min-h-screen p-8 bg-background">
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">🔍 Sessions Debug Page</h1>
<p className="text-muted-foreground mt-2">
View all your chat sessions and their workspace paths
</p>
{userId && (
<p className="text-xs text-muted-foreground mt-1">
User ID: <code className="bg-muted px-2 py-1 rounded">{userId}</code>
</p>
)}
</div>
<Button onClick={loadSessions} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{error && (
<Card className="border-red-500">
<CardContent className="pt-6">
<p className="text-red-600">Error: {error}</p>
</CardContent>
</Card>
)}
{loading && !error && (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Loading sessions...</p>
</CardContent>
</Card>
)}
{!loading && !error && (
<>
{/* Summary */}
<Card>
<CardHeader>
<CardTitle>Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total Sessions</p>
<p className="text-2xl font-bold">{sessions.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Linked to Projects</p>
<p className="text-2xl font-bold text-green-600">{associatedSessions.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Unassociated (Available)</p>
<p className="text-2xl font-bold text-orange-600">{unassociatedSessions.length}</p>
</div>
</div>
</CardContent>
</Card>
{/* Unassociated Sessions by Workspace */}
{unassociatedSessions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600" />
Unassociated Sessions (Available to Link)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{Object.entries(sessionsByWorkspace).map(([path, workspaceSessions]) => {
const folderName = path !== 'No workspace path' ? path.split('/').pop() : null;
return (
<div key={path} className="border rounded-lg p-4">
<div className="mb-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">📁 {folderName || 'Unknown folder'}</p>
<code className="text-xs text-muted-foreground break-all">{path}</code>
</div>
<div className="text-right">
<p className="text-2xl font-bold">{workspaceSessions.length}</p>
<p className="text-xs text-muted-foreground">sessions</p>
</div>
</div>
</div>
<div className="space-y-2">
{workspaceSessions.slice(0, 3).map((session) => (
<div key={session.id} className="text-xs bg-muted/50 p-2 rounded">
<div className="flex justify-between">
<span className="font-mono">{session.id.substring(0, 12)}...</span>
<span>{session.model || 'unknown'}</span>
</div>
<div className="text-muted-foreground">
{session.tokensUsed?.toLocaleString()} tokens ${session.cost?.toFixed(4)}
</div>
</div>
))}
{workspaceSessions.length > 3 && (
<p className="text-xs text-muted-foreground">
+ {workspaceSessions.length - 3} more sessions...
</p>
)}
</div>
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded text-sm">
<p className="text-blue-600 dark:text-blue-400 font-medium mb-1">
💡 To link these sessions:
</p>
<ol className="text-xs text-muted-foreground space-y-1 ml-4 list-decimal">
<li>Create a project with workspace path: <code className="bg-muted px-1 rounded">{path}</code></li>
<li>OR connect GitHub to a project that already has this workspace path set</li>
</ol>
<p className="text-xs text-muted-foreground mt-2">
Folder name: <code className="bg-muted px-1 rounded">{folderName}</code>
</p>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
{/* Associated Sessions */}
{associatedSessions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
Linked Sessions
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
These sessions are already linked to projects
</p>
<div className="space-y-2">
{associatedSessions.slice(0, 5).map((session) => (
<div key={session.id} className="flex items-center justify-between p-2 border rounded text-sm">
<div>
<code className="text-xs">{session.id.substring(0, 12)}...</code>
<p className="text-xs text-muted-foreground">
{session.workspaceName || 'No workspace'}
</p>
</div>
<div className="text-right">
<LinkIcon className="h-4 w-4 text-green-600" />
<p className="text-xs text-muted-foreground">
Project: {session.projectId?.substring(0, 8)}...
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{sessions.length === 0 && (
<Card>
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground">No sessions found. Start coding with Cursor to track sessions!</p>
</CardContent>
</Card>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
import { Toaster } from "sonner";
export default function KeysLayout({
children,
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("keys");
return (
<>
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
<Toaster position="top-center" />
</>
);
}

View File

@@ -1,412 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { auth } from '@/lib/firebase/config';
import { toast } from 'sonner';
import { Key, Plus, Trash2, Eye, EyeOff, ExternalLink, Save } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ApiKey {
id: string;
service: string;
name: string;
createdAt: any;
lastUsed: any;
}
const SUPPORTED_SERVICES = [
{
id: 'openai',
name: 'OpenAI',
description: 'For ChatGPT imports and AI features',
placeholder: 'sk-...',
helpUrl: 'https://platform.openai.com/api-keys',
},
{
id: 'github',
name: 'GitHub',
description: 'Personal access token for repository access',
placeholder: 'ghp_...',
helpUrl: 'https://github.com/settings/tokens',
},
{
id: 'anthropic',
name: 'Anthropic (Claude)',
description: 'For Claude AI integrations',
placeholder: 'sk-ant-...',
helpUrl: 'https://console.anthropic.com/settings/keys',
},
];
export default function KeysPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [selectedService, setSelectedService] = useState('');
const [keyValue, setKeyValue] = useState('');
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const user = auth.currentUser;
if (!user) return;
const token = await user.getIdToken();
const response = await fetch('/api/keys', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setKeys(data.keys);
}
} catch (error) {
console.error('Error loading keys:', error);
toast.error('Failed to load API keys');
} finally {
setLoading(false);
}
};
const handleAddKey = async () => {
if (!selectedService || !keyValue) {
toast.error('Please select a service and enter a key');
return;
}
setSaving(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
return;
}
const token = await user.getIdToken();
const service = SUPPORTED_SERVICES.find(s => s.id === selectedService);
const response = await fetch('/api/keys', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
service: selectedService,
name: service?.name,
keyValue,
}),
});
if (response.ok) {
toast.success(`${service?.name} key saved successfully`);
setShowAddDialog(false);
setSelectedService('');
setKeyValue('');
loadKeys();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to save key');
}
} catch (error) {
console.error('Error saving key:', error);
toast.error('Failed to save key');
} finally {
setSaving(false);
}
};
const handleDeleteKey = async (service: string, name: string) => {
try {
const user = auth.currentUser;
if (!user) return;
const token = await user.getIdToken();
const response = await fetch('/api/keys', {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ service }),
});
if (response.ok) {
toast.success(`${name} key deleted`);
loadKeys();
} else {
toast.error('Failed to delete key');
}
} catch (error) {
console.error('Error deleting key:', error);
toast.error('Failed to delete key');
}
};
const getServiceConfig = (serviceId: string) => {
return SUPPORTED_SERVICES.find(s => s.id === serviceId);
};
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold mb-2">API Keys</h1>
<p className="text-muted-foreground text-lg">
Manage your third-party API keys for Vibn integrations
</p>
</div>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add API Key</DialogTitle>
<DialogDescription>
Add a third-party API key for Vibn to use on your behalf
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="service">Service</Label>
<Select value={selectedService} onValueChange={setSelectedService}>
<SelectTrigger>
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{SUPPORTED_SERVICES.map(service => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedService && (
<p className="text-xs text-muted-foreground">
{getServiceConfig(selectedService)?.description}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="key">API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="key"
type={showKey ? 'text' : 'password'}
placeholder={getServiceConfig(selectedService)?.placeholder || 'Enter API key'}
value={keyValue}
onChange={(e) => setKeyValue(e.target.value)}
className="pr-10"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full"
onClick={() => setShowKey(!showKey)}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{selectedService && (
<a
href={getServiceConfig(selectedService)?.helpUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Get your API key <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-sm text-muted-foreground">
<strong>🔐 Secure Storage:</strong> Your API key will be encrypted and stored securely.
Vibn will only use it when you explicitly request actions that require it.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
Cancel
</Button>
<Button onClick={handleAddKey} disabled={saving || !selectedService || !keyValue}>
{saving ? 'Saving...' : 'Save Key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Keys List */}
{loading ? (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Loading your API keys...</p>
</CardContent>
</Card>
) : keys.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center space-y-4">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Key className="h-8 w-8 text-muted-foreground" />
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">No API keys yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Add your third-party API keys to enable Vibn features like ChatGPT imports and AI analysis
</p>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Your First Key
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{keys.map((key) => {
const serviceConfig = getServiceConfig(key.service);
return (
<Card key={key.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Key className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{key.name}</CardTitle>
<CardDescription>
{serviceConfig?.description || key.service}
</CardDescription>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete API Key?</AlertDialogTitle>
<AlertDialogDescription>
This will remove your {key.name} API key. Features using this key will stop working until you add a new one.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDeleteKey(key.service, key.name)}>
Delete Key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
<div className="space-y-1">
<p className="text-muted-foreground">
Added: {key.createdAt ? new Date(key.createdAt._seconds * 1000).toLocaleDateString() : 'Unknown'}
</p>
{key.lastUsed && (
<p className="text-muted-foreground">
Last used: {new Date(key.lastUsed._seconds * 1000).toLocaleDateString()}
</p>
)}
</div>
{serviceConfig && (
<a
href={serviceConfig.helpUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Manage on {serviceConfig.name} <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Info Card */}
<Card className="border-blue-500/20 bg-blue-500/5">
<CardHeader>
<CardTitle className="text-base">How API Keys Work</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<strong>🔐 Encrypted Storage:</strong> All API keys are encrypted before being stored in the database.
</p>
<p>
<strong>🎯 Automatic Usage:</strong> When you use Vibn features (like ChatGPT import), we'll automatically use your stored keys instead of asking each time.
</p>
<p>
<strong>🔄 Easy Updates:</strong> Add a new key with the same service name to replace an existing one.
</p>
<p>
<strong>🗑 Full Control:</strong> Delete keys anytime - you can always add them back later.
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
/**
* MCP Integration Page
*
* Test and demonstrate Vibn's Model Context Protocol capabilities
*/
import { MCPPlayground } from '@/components/mcp-playground';
export const metadata = {
title: 'MCP Integration | Vibn',
description: 'Connect AI assistants to your Vibn projects using the Model Context Protocol',
};
export default function MCPPage() {
return (
<div className="container max-w-6xl py-8">
<MCPPlayground />
</div>
);
}

View File

@@ -1,8 +0,0 @@
export default function NewProjectLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -1,506 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowLeft, ArrowRight, Check, Sparkles, Code2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { auth } from "@/lib/firebase/config";
import { toast } from "sonner";
type ProjectType = "scratch" | "existing" | null;
export default function NewProjectPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [step, setStep] = useState(1);
const [projectName, setProjectName] = useState("");
const [projectType, setProjectType] = useState<ProjectType>(null);
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
// Product vision (can skip)
const [productVision, setProductVision] = useState("");
// Product details
const [productName, setProductName] = useState("");
const [isForClient, setIsForClient] = useState<boolean | null>(null);
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
const [hasDomain, setHasDomain] = useState<boolean | null>(null);
const [hasWebsite, setHasWebsite] = useState<boolean | null>(null);
const [hasGithub, setHasGithub] = useState<boolean | null>(null);
const [hasChatGPT, setHasChatGPT] = useState<boolean | null>(null);
const [isCheckingSlug, setIsCheckingSlug] = useState(false);
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
// Check for workspacePath query parameter
useEffect(() => {
const path = searchParams.get('workspacePath');
if (path) {
setWorkspacePath(path);
// Auto-fill project name from workspace path
const folderName = path.split('/').pop();
if (folderName && !projectName) {
setProjectName(folderName.replace(/-/g, ' ').replace(/_/g, ' '));
}
}
}, [searchParams, projectName]);
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const checkSlugAvailability = async (name: string) => {
const slug = generateSlug(name);
if (!slug) return;
setIsCheckingSlug(true);
// TODO: Replace with actual API call
await new Promise(resolve => setTimeout(resolve, 500));
// Mock check - in reality, check against database
const isAvailable = !["test", "demo", "admin"].includes(slug);
setSlugAvailable(isAvailable);
setIsCheckingSlug(false);
};
const handleProductNameChange = (value: string) => {
setProductName(value);
setSlugAvailable(null);
if (value.length > 2) {
checkSlugAvailability(value);
}
};
const handleNext = () => {
if (step === 1 && projectName && projectType) {
setStep(2);
} else if (step === 2) {
// Can skip questions
setStep(3);
} else if (step === 3 && productName && slugAvailable) {
handleCreateProject();
}
};
const handleBack = () => {
if (step > 1) setStep(step - 1);
};
const handleSkipQuestions = () => {
setStep(3);
};
const handleCreateProject = async () => {
const slug = generateSlug(productName);
const projectData = {
projectName,
projectType,
slug,
vision: productVision,
product: {
name: productName,
isForClient,
hasLogo,
hasDomain,
hasWebsite,
hasGithub,
hasChatGPT,
},
workspacePath,
};
try {
const user = auth.currentUser;
if (!user) {
toast.error('You must be signed in to create a project');
return;
}
const token = await user.getIdToken();
const response = await fetch('/api/projects/create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(projectData),
});
if (response.ok) {
const data = await response.json();
toast.success('Project created successfully!');
// Redirect to AI chat to start with vision questions
router.push(`/${data.workspace}/project/${data.projectId}/v_ai_chat`);
} else {
const error = await response.json();
toast.error(error.error || 'Failed to create project');
}
} catch (error) {
console.error('Error creating project:', error);
toast.error('An error occurred while creating project');
}
};
const canProceedStep1 = projectName.trim() && projectType;
const canProceedStep3 = productName.trim() && slugAvailable;
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-2xl">
{/* Header */}
<div className="mb-8">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/projects")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Projects
</Button>
<h1 className="text-3xl font-bold">Create New Project</h1>
<p className="text-muted-foreground mt-2">
Step {step} of 3
</p>
</div>
{/* Progress */}
<div className="flex gap-2 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-2 flex-1 rounded-full transition-colors ${
s <= step ? "bg-primary" : "bg-muted"
}`}
/>
))}
</div>
{/* Step 1: Project Setup */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Project Setup</CardTitle>
<CardDescription>
Give your project a name and choose how you want to start
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="projectName">Project Name</Label>
<Input
id="projectName"
placeholder="My Awesome Project"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
/>
</div>
<div className="space-y-3">
<Label>Starting Point</Label>
<div className="grid gap-3">
<button
onClick={() => setProjectType("scratch")}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
projectType === "scratch"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 mt-0.5 text-primary" />
<div>
<div className="font-medium">Start from scratch</div>
<div className="text-sm text-muted-foreground">
Build a new project with AI assistance
</div>
</div>
{projectType === "scratch" && (
<Check className="h-5 w-5 ml-auto text-primary" />
)}
</div>
</button>
<button
onClick={() => setProjectType("existing")}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
projectType === "existing"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-start gap-3">
<Code2 className="h-5 w-5 mt-0.5 text-primary" />
<div>
<div className="font-medium">Existing project</div>
<div className="text-sm text-muted-foreground">
Import and enhance an existing codebase
</div>
</div>
{projectType === "existing" && (
<Check className="h-5 w-5 ml-auto text-primary" />
)}
</div>
</button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Product Vision */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle>Describe your product vision</CardTitle>
<CardDescription>
Help us understand your project (you can skip this)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Textarea
placeholder="Describe who you're building for, what problem they have, and how you plan to solve it..."
value={productVision}
onChange={(e) => setProductVision(e.target.value)}
rows={8}
className="resize-none"
/>
</div>
<Button
variant="ghost"
className="w-full"
onClick={handleSkipQuestions}
>
Skip this step
</Button>
</CardContent>
</Card>
)}
{/* Step 3: Product Details */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle>Product Details</CardTitle>
<CardDescription>
Tell us about your product
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="productName">Product Name *</Label>
<Input
id="productName"
placeholder="Taskify"
value={productName}
onChange={(e) => handleProductNameChange(e.target.value)}
/>
{productName && (
<div className="text-xs text-muted-foreground">
{isCheckingSlug ? (
<span>Checking availability...</span>
) : slugAvailable === true ? (
<span className="text-green-600">
URL available: vibn.app/{generateSlug(productName)}
</span>
) : slugAvailable === false ? (
<span className="text-red-600">
This name is already taken
</span>
) : null}
</div>
)}
</div>
<div className="space-y-4">
{/* Client or Self */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Is this for a client or yourself?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={isForClient === true ? "default" : "outline"}
onClick={() => setIsForClient(true)}
size="sm"
className="w-20 h-8"
>
Client
</Button>
<Button
type="button"
variant={isForClient === false ? "default" : "outline"}
onClick={() => setIsForClient(false)}
size="sm"
className="w-20 h-8"
>
Myself
</Button>
</div>
</div>
{/* Logo */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Does it have a logo?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasLogo === true ? "default" : "outline"}
onClick={() => setHasLogo(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasLogo === false ? "default" : "outline"}
onClick={() => setHasLogo(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* Domain */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Does it have a domain?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasDomain === true ? "default" : "outline"}
onClick={() => setHasDomain(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasDomain === false ? "default" : "outline"}
onClick={() => setHasDomain(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* Website */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Does it have a website?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasWebsite === true ? "default" : "outline"}
onClick={() => setHasWebsite(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasWebsite === false ? "default" : "outline"}
onClick={() => setHasWebsite(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* GitHub */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Do you have a GitHub repository?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasGithub === true ? "default" : "outline"}
onClick={() => setHasGithub(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasGithub === false ? "default" : "outline"}
onClick={() => setHasGithub(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* ChatGPT */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Do you have your ideas in a ChatGPT project?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasChatGPT === true ? "default" : "outline"}
onClick={() => setHasChatGPT(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasChatGPT === false ? "default" : "outline"}
onClick={() => setHasChatGPT(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex gap-3 mt-6">
{step > 1 && (
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
<Button
className="ml-auto"
onClick={handleNext}
disabled={
(step === 1 && !canProceedStep1) ||
(step === 3 && !canProceedStep3) ||
isCheckingSlug
}
>
{step === 3 ? "Create Project" : "Next"}
{step < 3 && <ArrowRight className="h-4 w-4 ml-2" />}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,179 +1,133 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart3, DollarSign, TrendingUp, Zap } from "lucide-react";
"use client";
import { Suspense } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
const SECTIONS = [
{
id: "customers",
label: "Customers",
icon: "◉",
title: "Customer List",
desc: "Every user who has signed up, their plan, usage, last seen, and lifecycle stage. Filter, search, and act on any segment.",
items: ["User Directory", "Lifecycle Stages", "Plan & Billing", "Activity Timeline", "Segment Builder"],
},
{
id: "usage",
label: "Usage",
icon: "∿",
title: "Usage & Activity",
desc: "How users interact with your product — feature adoption, session frequency, retention curves, and activation funnels.",
items: ["Feature Adoption", "Session Metrics", "Retention Curves", "Activation Funnel", "Power Users"],
},
{
id: "events",
label: "Events",
icon: "◬",
title: "Events & Tracking",
desc: "Every event your product fires — page views, clicks, conversions, and custom events — all tagged and queryable.",
items: ["Event Stream", "Custom Events", "Page Views", "Conversion Events", "Tag Manager"],
},
{
id: "reports",
label: "Reports",
icon: "▭",
title: "Reports",
desc: "MRR, churn, DAU/MAU, cohort analysis, and revenue reports. Export or share with your team on a schedule.",
items: ["Revenue (MRR/ARR)", "Churn Report", "DAU / MAU", "Cohort Analysis", "Custom Reports", "Scheduled Exports"],
},
] as const;
type SectionId = typeof SECTIONS[number]["id"];
const NAV_GROUP: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
};
function AnalyticsInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const activeId = (searchParams.get("section") ?? "customers") as SectionId;
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
const setSection = (id: string) =>
router.push(`/${workspace}/project/${projectId}/analytics?section=${id}`, { scroll: false });
export default async function AnalyticsPage({
params,
}: {
params: { projectId: string };
}) {
return (
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="border-b bg-card/50 px-6 py-4">
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="text-sm text-muted-foreground">
Cost analysis, token usage, and performance metrics
</p>
</div>
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* Left nav */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
<div style={NAV_GROUP}>Analytics</div>
{SECTIONS.map(s => {
const isActive = activeId === s.id;
return (
<button key={s.id} onClick={() => setSection(s.id)} style={{
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: "6px 12px", borderRadius: 5,
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
color: isActive ? "#1a1a1a" : "#5a5550",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
{s.label}
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl space-y-6">
{/* Key Metrics */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$12.50</div>
<p className="text-xs text-muted-foreground">
<TrendingUp className="mr-1 inline h-3 w-3" />
+8% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tokens Used</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2.5M</div>
<p className="text-xs text-muted-foreground">Across all sessions</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Cost/Session</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$0.30</div>
<p className="text-xs text-muted-foreground">Per coding session</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cost/Feature</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$1.56</div>
<p className="text-xs text-muted-foreground">Average per feature</p>
</CardContent>
</Card>
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
</div>
{/* Detailed Analytics */}
<Tabs defaultValue="costs" className="space-y-4">
<TabsList>
<TabsTrigger value="costs">Costs</TabsTrigger>
<TabsTrigger value="tokens">Tokens</TabsTrigger>
<TabsTrigger value="performance">Performance</TabsTrigger>
</TabsList>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
{active.items.map(item => (
<div key={item} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
</div>
))}
</div>
<TabsContent value="costs" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Cost Breakdown</CardTitle>
<CardDescription>
AI usage costs over time
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
Cost chart visualization coming soon
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost by Model</CardTitle>
<CardDescription>
Breakdown by AI model used
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ model: "Claude Sonnet 4", cost: "$8.20", percentage: 66 },
{ model: "GPT-4", cost: "$3.10", percentage: 25 },
{ model: "Gemini Pro", cost: "$1.20", percentage: 9 },
].map((item, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{item.model}</span>
<span className="text-muted-foreground">{item.cost}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tokens" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Token Usage</CardTitle>
<CardDescription>
Token consumption over time
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
Token usage chart coming soon
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Development Velocity</CardTitle>
<CardDescription>
Features completed over time
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
Velocity metrics coming soon
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div style={{
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
borderRadius: 12, padding: "24px 28px",
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
}}>
<div>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We&apos;re building this section next. Shape it by telling us what you need.</div>
</div>
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
Give feedback
</button>
</div>
</div>
</div>
</div>
);
}
export default function AnalyticsPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<AnalyticsInner />
</Suspense>
);
}

View File

@@ -1,74 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Map } from "lucide-react";
export default async function ApiMapPage({
params,
}: {
params: { projectId: string };
}) {
return (
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="border-b bg-card/50 px-6 py-4">
<div>
<h1 className="text-2xl font-bold">API Map</h1>
<p className="text-sm text-muted-foreground">
Auto-generated API endpoint documentation
</p>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl">
<Card>
<CardHeader>
<CardTitle>API Endpoints</CardTitle>
<CardDescription>
Automatically detected from your codebase
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Example endpoints */}
{[
{ method: "GET", path: "/api/sessions", desc: "List all sessions" },
{ method: "POST", path: "/api/sessions", desc: "Create new session" },
{ method: "GET", path: "/api/features", desc: "List features" },
].map((endpoint, i) => (
<div
key={i}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<Badge
variant={endpoint.method === "GET" ? "outline" : "default"}
className="font-mono"
>
{endpoint.method}
</Badge>
<div>
<code className="text-sm font-mono">{endpoint.path}</code>
<p className="text-sm text-muted-foreground">
{endpoint.desc}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,131 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FileCode } from "lucide-react";
export default async function ArchitecturePage({
params,
}: {
params: { projectId: string };
}) {
return (
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="border-b bg-card/50 px-6 py-4">
<div>
<h1 className="text-2xl font-bold">Architecture</h1>
<p className="text-sm text-muted-foreground">
Living architecture documentation and ADRs
</p>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl">
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="decisions">Decisions (ADRs)</TabsTrigger>
<TabsTrigger value="tech-stack">Tech Stack</TabsTrigger>
<TabsTrigger value="data-model">Data Model</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Architecture Overview</CardTitle>
<CardDescription>
High-level system architecture
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose max-w-none">
<p className="text-muted-foreground">
Architecture documentation will be automatically generated
from your code and conversations.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="decisions" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Architectural Decision Records</CardTitle>
<CardDescription>
Key architectural choices and their context
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4 rounded-full bg-muted p-3">
<FileCode className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-center text-muted-foreground max-w-sm">
ADRs will be automatically detected from your AI conversations
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tech-stack" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Technology Stack</CardTitle>
<CardDescription>
Approved technologies and frameworks
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="font-medium mb-2">Frontend</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
<li> Next.js 15</li>
<li> React 19</li>
<li> Tailwind CSS</li>
</ul>
</div>
<div>
<h4 className="font-medium mb-2">Backend</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
<li> Node.js</li>
<li> Express</li>
<li> PostgreSQL</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="data-model" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Data Model</CardTitle>
<CardDescription>
Database schema and relationships
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Database schema documentation coming soon
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { Suspense } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
const SECTIONS = [
{
id: "emails",
label: "Emails",
icon: "◈",
title: "Email",
desc: "Transactional and support emails — onboarding sequences, password resets, billing receipts, and support replies — all in one place.",
items: ["Onboarding Sequence", "Transactional Emails", "Support Replies", "Billing Notices", "Digests & Summaries"],
},
{
id: "chat",
label: "Chat Support",
icon: "◎",
title: "Chat Support",
desc: "Live chat and AI-powered support widget embedded in your product. Routes to human agents when needed, logs every conversation.",
items: ["Live Chat Widget", "AI First Response", "Agent Handoff", "Conversation History", "Canned Responses"],
},
{
id: "support-site",
label: "Support Site",
icon: "▭",
title: "Support Site",
desc: "Your public help centre — searchable docs, FAQs, guides, and tutorials. Deflects support tickets before they're created.",
items: ["Help Articles", "FAQs", "Video Guides", "Release Notes", "Status Page"],
},
{
id: "communications",
label: "Communications",
icon: "↗",
title: "In-App Communications",
desc: "Announcements, tooltips, banners, and nudges shown directly inside your product to guide and inform users.",
items: ["In-App Banners", "Tooltips & Tours", "Feature Announcements", "NPS Surveys", "Feedback Prompts"],
},
] as const;
type SectionId = typeof SECTIONS[number]["id"];
const NAV_GROUP: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
};
function AssistInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const activeId = (searchParams.get("section") ?? "emails") as SectionId;
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
const setSection = (id: string) =>
router.push(`/${workspace}/project/${projectId}/assist?section=${id}`, { scroll: false });
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* Left nav */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
<div style={NAV_GROUP}>Assist</div>
{SECTIONS.map(s => {
const isActive = activeId === s.id;
return (
<button key={s.id} onClick={() => setSection(s.id)} style={{
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: "6px 12px", borderRadius: 5,
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
color: isActive ? "#1a1a1a" : "#5a5550",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
{s.label}
</button>
);
})}
</div>
{/* Content */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
{active.items.map(item => (
<div key={item} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
</div>
))}
</div>
<div style={{
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
borderRadius: 12, padding: "24px 28px",
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
}}>
<div>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We&apos;re building this section next. Shape it by telling us what you need.</div>
</div>
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
Give feedback
</button>
</div>
</div>
</div>
</div>
);
}
export default function AssistPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<AssistInner />
</Suspense>
);
}

View File

@@ -1,223 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { auth, db } from '@/lib/firebase/config';
import { doc, getDoc } from 'firebase/firestore';
import { toast } from 'sonner';
import { Loader2, Link as LinkIcon, CheckCircle2 } from 'lucide-react';
import { useParams } from 'next/navigation';
interface Project {
id: string;
productName: string;
githubRepo?: string;
workspacePath?: string;
}
export default function AssociateSessionsPage() {
const params = useParams();
const projectId = params.projectId as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [associating, setAssociating] = useState(false);
const [result, setResult] = useState<any>(null);
useEffect(() => {
loadProject();
}, [projectId]);
const loadProject = async () => {
try {
const projectDoc = await getDoc(doc(db, 'projects', projectId));
if (projectDoc.exists()) {
setProject({ id: projectDoc.id, ...projectDoc.data() } as Project);
}
} catch (error) {
console.error('Error loading project:', error);
toast.error('Failed to load project');
} finally {
setLoading(false);
}
};
const handleAssociateSessions = async () => {
if (!project?.githubRepo) {
toast.error('Project does not have a GitHub repository connected');
return;
}
setAssociating(true);
setResult(null);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
return;
}
const token = await user.getIdToken();
const response = await fetch(`/api/projects/${projectId}/associate-github-sessions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
githubRepo: project.githubRepo,
}),
});
if (response.ok) {
const data = await response.json();
setResult(data);
if (data.sessionsAssociated > 0) {
toast.success(`Success!`, {
description: `Linked ${data.sessionsAssociated} existing chat sessions to this project`,
});
} else {
toast.info('No unassociated sessions found for this repository');
}
} else {
const error = await response.json();
toast.error(error.error || 'Failed to associate sessions');
}
} catch (error) {
console.error('Error:', error);
toast.error('An error occurred');
} finally {
setAssociating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="container max-w-4xl mx-auto p-8 space-y-6">
<div>
<h1 className="text-3xl font-bold">Associate Existing Sessions</h1>
<p className="text-muted-foreground mt-2">
Find and link chat sessions from this GitHub repository
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
<CardDescription>Current project configuration</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">Product Name</p>
<p className="font-medium">{project?.productName}</p>
</div>
{project?.githubRepo && (
<div>
<p className="text-sm text-muted-foreground">GitHub Repository</p>
<p className="font-medium font-mono text-sm">{project.githubRepo}</p>
</div>
)}
{project?.workspacePath && (
<div>
<p className="text-sm text-muted-foreground">Workspace Path</p>
<p className="font-medium font-mono text-sm">{project.workspacePath}</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Find Matching Sessions</CardTitle>
<CardDescription>
Search your database for chat sessions that match this project's GitHub repository
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/50 p-4 rounded-lg space-y-2 text-sm">
<p><strong>How it works:</strong></p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Searches for sessions with matching GitHub repository</li>
<li>Also checks sessions from matching workspace paths</li>
<li>Only links sessions that aren't already assigned to a project</li>
<li>Updates all matched sessions to link to this project</li>
</ul>
</div>
<Button
onClick={handleAssociateSessions}
disabled={!project?.githubRepo || associating}
className="w-full"
size="lg"
>
{associating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Searching...
</>
) : (
<>
<LinkIcon className="mr-2 h-4 w-4" />
Find and Link Sessions
</>
)}
</Button>
{!project?.githubRepo && (
<p className="text-sm text-muted-foreground text-center">
Connect a GitHub repository first to use this feature
</p>
)}
</CardContent>
</Card>
{result && (
<Card className="border-green-500/50 bg-green-50/50 dark:bg-green-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
Results
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Sessions Linked</p>
<p className="text-2xl font-bold">{result.sessionsAssociated}</p>
</div>
{result.details && (
<>
<div>
<p className="text-sm text-muted-foreground">Exact GitHub Matches</p>
<p className="text-2xl font-bold">{result.details.exactMatches}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Path Matches</p>
<p className="text-2xl font-bold">{result.details.pathMatches}</p>
</div>
</>
)}
</div>
<p className="text-sm text-muted-foreground">
{result.message}
</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,17 +0,0 @@
'use client';
export default function AuditTestPage() {
return (
<div className="p-8">
<h1 className="text-3xl font-bold">Audit Test Page</h1>
<p className="mt-4">If you can see this, routing is working!</p>
<button
onClick={() => alert('Button works!')}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
Test Button
</button>
</div>
);
}

View File

@@ -1,956 +0,0 @@
'use client';
import { use, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Loader2, FileText, TrendingUp, DollarSign, Code, Calendar, Clock } from 'lucide-react';
interface AuditReport {
projectId: string;
generatedAt: string;
timeline: {
firstActivity: string | null;
lastActivity: string | null;
totalDays: number;
activeDays: number;
totalSessions: number;
sessions: Array<{
sessionId: string;
date: string;
startTime: string;
endTime: string;
duration: number;
messageCount: number;
userMessages: number;
aiMessages: number;
topics: string[];
filesWorkedOn: string[];
}>;
velocity: {
messagesPerDay: number;
averageSessionLength: number;
peakProductivityHours: number[];
};
};
costs: {
messageStats: {
totalMessages: number;
userMessages: number;
aiMessages: number;
avgMessageLength: number;
};
estimatedTokens: {
input: number;
output: number;
total: number;
};
costs: {
inputCost: number;
outputCost: number;
totalCost: number;
currency: string;
};
model: string;
pricing: {
inputPer1M: number;
outputPer1M: number;
};
};
features: Array<{
name: string;
description: string;
pages: string[];
apis: string[];
status: string;
}>;
techStack: {
frontend: Record<string, string>;
backend: Record<string, string>;
integrations: string[];
};
extensionActivity: {
totalSessions: number;
uniqueFilesEdited: number;
topFiles: Array<{ file: string; editCount: number }>;
earliestActivity: string | null;
latestActivity: string | null;
} | null;
gitHistory: {
totalCommits: number;
firstCommit: string | null;
lastCommit: string | null;
totalFilesChanged: number;
totalInsertions: number;
totalDeletions: number;
commits: Array<{
hash: string;
date: string;
author: string;
message: string;
filesChanged: number;
insertions: number;
deletions: number;
}>;
topFiles: Array<{ filePath: string; changeCount: number }>;
commitsByDay: Record<string, number>;
authors: Array<{ name: string; commitCount: number }>;
} | null;
unifiedTimeline: {
projectId: string;
dateRange: {
earliest: string;
latest: string;
totalDays: number;
};
days: Array<{
date: string;
dayOfWeek: string;
gitCommits: any[];
extensionSessions: any[];
cursorMessages: any[];
summary: {
totalGitCommits: number;
totalExtensionSessions: number;
totalCursorMessages: number;
linesAdded: number;
linesRemoved: number;
uniqueFilesModified: number;
};
}>;
dataSources: {
git: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
extension: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
cursor: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
};
} | null;
summary: {
totalConversations: number;
totalMessages: number;
developmentPeriod: number;
estimatedCost: number;
extensionSessions: number;
filesEdited: number;
gitCommits: number;
linesAdded: number;
linesRemoved: number;
timelineDays: number;
};
}
export default function ProjectAuditPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = use(params);
const [report, setReport] = useState<AuditReport | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const generateReport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/projects/${projectId}/audit/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to generate report');
}
const data = await response.json();
setReport(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
return (
<div className="container mx-auto py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Project Audit Report</h1>
<p className="text-muted-foreground mt-2">
Comprehensive analysis of development history, costs, and architecture
</p>
</div>
<Button onClick={generateReport} disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<FileText className="mr-2 h-4 w-4" />
Generate Report
</>
)}
</Button>
</div>
{error && (
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Error</CardTitle>
</CardHeader>
<CardContent>
<p>{error}</p>
{error.includes('No conversations found') && (
<p className="mt-2 text-sm text-muted-foreground">
Import Cursor conversations first to generate an audit report.
</p>
)}
</CardContent>
</Card>
)}
{!report && !loading && !error && (
<Card>
<CardHeader>
<CardTitle>Ready to Generate</CardTitle>
<CardDescription>
Click the button above to analyze your project's development history,
calculate costs, and document your architecture.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="flex items-center space-x-3">
<Calendar className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-semibold">Timeline Analysis</p>
<p className="text-sm text-muted-foreground">Work sessions & velocity</p>
</div>
</div>
<div className="flex items-center space-x-3">
<DollarSign className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-semibold">Cost Estimation</p>
<p className="text-sm text-muted-foreground">AI & developer costs</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Code className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-semibold">Architecture</p>
<p className="text-sm text-muted-foreground">Features & tech stack</p>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{report && (
<div className="space-y-6">
{/* Summary Section */}
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Messages
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(report.summary.totalMessages)}</div>
<p className="text-xs text-muted-foreground mt-1">
{report.summary.totalConversations} conversations
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Development Period
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{report.summary.developmentPeriod} days</div>
<p className="text-xs text-muted-foreground mt-1">
{report.timeline.activeDays} active days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Work Sessions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{report.timeline.totalSessions}</div>
<p className="text-xs text-muted-foreground mt-1">
Avg {report.timeline.velocity.averageSessionLength} min
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
AI Cost
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(report.summary.estimatedCost)}</div>
<p className="text-xs text-muted-foreground mt-1">
{report.costs.model}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Git Commits
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(report.summary.gitCommits)}</div>
<p className="text-xs text-muted-foreground mt-1">
Code changes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Lines Changed
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-lg font-bold">
<span className="text-green-600">+{formatNumber(report.summary.linesAdded)}</span>
{' / '}
<span className="text-red-600">-{formatNumber(report.summary.linesRemoved)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Total modifications
</p>
</CardContent>
</Card>
</div>
{/* Unified Timeline Section */}
{report.unifiedTimeline && (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Complete Project Timeline
</CardTitle>
<CardDescription>
Day-by-day history combining Git commits, Extension activity, and Cursor messages
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Data Source Overview */}
<div className="grid gap-4 md:grid-cols-3 mb-6">
<div className={`border rounded-lg p-3 ${report.unifiedTimeline.dataSources.git.available ? 'bg-green-50 border-green-200' : 'bg-gray-50'}`}>
<p className="text-sm font-medium mb-1">📊 Git Commits</p>
<p className="text-xs text-muted-foreground">
{report.unifiedTimeline.dataSources.git.available ? (
<>
{report.unifiedTimeline.dataSources.git.totalRecords} commits<br/>
{formatDate(report.unifiedTimeline.dataSources.git.firstDate)} to {formatDate(report.unifiedTimeline.dataSources.git.lastDate)}
</>
) : 'No data'}
</p>
</div>
<div className={`border rounded-lg p-3 ${report.unifiedTimeline.dataSources.extension.available ? 'bg-blue-50 border-blue-200' : 'bg-gray-50'}`}>
<p className="text-sm font-medium mb-1">💻 Extension Activity</p>
<p className="text-xs text-muted-foreground">
{report.unifiedTimeline.dataSources.extension.available ? (
<>
{report.unifiedTimeline.dataSources.extension.totalRecords} sessions<br/>
{formatDate(report.unifiedTimeline.dataSources.extension.firstDate)} to {formatDate(report.unifiedTimeline.dataSources.extension.lastDate)}
</>
) : 'No data'}
</p>
</div>
<div className={`border rounded-lg p-3 ${report.unifiedTimeline.dataSources.cursor.available ? 'bg-purple-50 border-purple-200' : 'bg-gray-50'}`}>
<p className="text-sm font-medium mb-1">🤖 Cursor Messages</p>
<p className="text-xs text-muted-foreground">
{report.unifiedTimeline.dataSources.cursor.available ? (
<>
{report.unifiedTimeline.dataSources.cursor.totalRecords} messages<br/>
{formatDate(report.unifiedTimeline.dataSources.cursor.firstDate)} to {formatDate(report.unifiedTimeline.dataSources.cursor.lastDate)}
</>
) : 'No data'}
</p>
</div>
</div>
<Separator />
{/* Timeline Days */}
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{report.unifiedTimeline.days.filter(day =>
day.summary.totalGitCommits > 0 ||
day.summary.totalExtensionSessions > 0 ||
day.summary.totalCursorMessages > 0
).reverse().map((day, index) => (
<div key={index} className="border-l-4 border-primary/30 pl-4 py-3 hover:bg-accent/50 rounded-r-lg transition-colors">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold">{formatDate(day.date)}</h4>
<p className="text-xs text-muted-foreground">{day.dayOfWeek}</p>
</div>
<div className="flex gap-2 text-xs">
{day.summary.totalGitCommits > 0 && (
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
📊 {day.summary.totalGitCommits}
</span>
)}
{day.summary.totalExtensionSessions > 0 && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
💻 {day.summary.totalExtensionSessions}
</span>
)}
{day.summary.totalCursorMessages > 0 && (
<span className="px-2 py-1 bg-purple-100 text-purple-800 rounded">
🤖 {day.summary.totalCursorMessages}
</span>
)}
</div>
</div>
<div className="space-y-2 text-sm">
{/* Git Commits */}
{day.gitCommits.length > 0 && (
<div className="bg-green-50 rounded p-2">
<p className="text-xs font-medium text-green-900 mb-1">Git Commits:</p>
{day.gitCommits.map((commit: any, idx: number) => (
<div key={idx} className="text-xs text-green-800 ml-2">
• {commit.message}
<span className="text-green-600 ml-1">
(+{commit.insertions}/-{commit.deletions})
</span>
</div>
))}
</div>
)}
{/* Extension Sessions */}
{day.extensionSessions.length > 0 && (
<div className="bg-blue-50 rounded p-2">
<p className="text-xs font-medium text-blue-900 mb-1">
Extension Sessions: {day.summary.totalExtensionSessions}
({day.summary.uniqueFilesModified} files modified)
</p>
{day.extensionSessions.slice(0, 3).map((session: any, idx: number) => (
<div key={idx} className="text-xs text-blue-800 ml-2">
• {session.duration} min session
{session.conversationSummary && (
<span className="ml-1">- {session.conversationSummary.substring(0, 50)}...</span>
)}
</div>
))}
{day.extensionSessions.length > 3 && (
<p className="text-xs text-blue-600 ml-2 mt-1">
+{day.extensionSessions.length - 3} more sessions
</p>
)}
</div>
)}
{/* Cursor Messages */}
{day.cursorMessages.length > 0 && (
<div className="bg-purple-50 rounded p-2">
<p className="text-xs font-medium text-purple-900 mb-1">
AI Conversations: {day.summary.totalCursorMessages} messages
</p>
<div className="text-xs text-purple-800 ml-2">
• Active in: {[...new Set(day.cursorMessages.map((m: any) => m.conversationName))].join(', ')}
</div>
</div>
)}
</div>
{/* Day Summary */}
{(day.summary.linesAdded > 0 || day.summary.linesRemoved > 0) && (
<div className="mt-2 pt-2 border-t text-xs text-muted-foreground">
Total changes: <span className="text-green-600">+{day.summary.linesAdded}</span> /
<span className="text-red-600"> -{day.summary.linesRemoved}</span> lines
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Timeline Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Development Timeline
</CardTitle>
<CardDescription>
Work sessions and development velocity
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium mb-1">Development Period</p>
<p className="text-2xl font-bold">{formatDate(report.timeline.firstActivity)}</p>
<p className="text-sm text-muted-foreground">to {formatDate(report.timeline.lastActivity)}</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Peak Productivity Hours</p>
<p className="text-2xl font-bold">
{report.timeline.velocity.peakProductivityHours.map(h => `${h}:00`).join(', ')}
</p>
<p className="text-sm text-muted-foreground">Most active times</p>
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-3">Velocity Metrics</p>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Messages per day:</span>
<span className="font-mono">{report.timeline.velocity.messagesPerDay.toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Average session length:</span>
<span className="font-mono">{report.timeline.velocity.averageSessionLength} minutes</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total sessions:</span>
<span className="font-mono">{report.timeline.totalSessions}</span>
</div>
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-3">Recent Sessions</p>
<div className="space-y-2">
{report.timeline.sessions.slice(-5).reverse().map((session) => (
<div key={session.sessionId} className="border rounded-lg p-3 text-sm">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{formatDate(session.date)}</span>
<span className="text-muted-foreground font-mono">
<Clock className="inline h-3 w-3 mr-1" />
{session.duration} min
</span>
</div>
<div className="text-xs text-muted-foreground">
{session.messageCount} messages • {session.topics.slice(0, 2).join(', ')}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Extension Activity Section */}
{report.extensionActivity && (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Code className="mr-2 h-5 w-5" />
File Edit Activity
</CardTitle>
<CardDescription>
Files you've edited tracked by the Cursor Monitor extension
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm font-medium mb-1">Extension Sessions</p>
<p className="text-2xl font-bold">{report.extensionActivity.totalSessions}</p>
<p className="text-xs text-muted-foreground mt-1">Work sessions logged</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Files Edited</p>
<p className="text-2xl font-bold">{report.extensionActivity.uniqueFilesEdited}</p>
<p className="text-xs text-muted-foreground mt-1">Unique files modified</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Activity Period</p>
<p className="text-sm font-bold">
{report.extensionActivity.earliestActivity
? formatDate(report.extensionActivity.earliestActivity)
: 'N/A'}
</p>
<p className="text-xs text-muted-foreground mt-1">
to {report.extensionActivity.latestActivity
? formatDate(report.extensionActivity.latestActivity)
: 'N/A'}
</p>
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-3">Most Edited Files (Top 20)</p>
<div className="space-y-2 max-h-96 overflow-y-auto">
{report.extensionActivity.topFiles.map((item, index) => (
<div key={index} className="flex items-center justify-between border-b pb-2">
<span className="text-sm font-mono truncate flex-1" title={item.file}>
{item.file.split('/').pop()}
</span>
<span className="text-xs text-muted-foreground ml-2">
{item.editCount} {item.editCount === 1 ? 'edit' : 'edits'}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Git Commit History Section */}
{report.gitHistory && (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="mr-2 h-5 w-5" />
Git Commit History
</CardTitle>
<CardDescription>
Complete development history from Git repository
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm font-medium mb-1">Total Commits</p>
<p className="text-2xl font-bold">{report.gitHistory.totalCommits}</p>
<p className="text-xs text-muted-foreground mt-1">Code changes tracked</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Lines of Code</p>
<p className="text-2xl font-bold text-green-600">
+{formatNumber(report.gitHistory.totalInsertions)}
</p>
<p className="text-2xl font-bold text-red-600">
-{formatNumber(report.gitHistory.totalDeletions)}
</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Repository Period</p>
<p className="text-sm font-bold">
{report.gitHistory.firstCommit
? formatDate(report.gitHistory.firstCommit)
: 'N/A'}
</p>
<p className="text-xs text-muted-foreground mt-1">
to {report.gitHistory.lastCommit
? formatDate(report.gitHistory.lastCommit)
: 'N/A'}
</p>
</div>
</div>
<Separator />
{/* Authors */}
{report.gitHistory.authors.length > 0 && (
<>
<div>
<p className="text-sm font-medium mb-3">Contributors</p>
<div className="flex flex-wrap gap-2">
{report.gitHistory.authors.map((author, index) => (
<span key={index} className="text-xs px-3 py-1 bg-secondary rounded-full">
{author.name} ({author.commitCount} {author.commitCount === 1 ? 'commit' : 'commits'})
</span>
))}
</div>
</div>
<Separator />
</>
)}
{/* Top Files */}
<div>
<p className="text-sm font-medium mb-3">Most Changed Files (Top 20)</p>
<div className="space-y-2 max-h-96 overflow-y-auto">
{report.gitHistory.topFiles.map((item, index) => (
<div key={index} className="flex items-center justify-between border-b pb-2">
<span className="text-sm font-mono truncate flex-1" title={item.filePath}>
{item.filePath.split('/').pop()}
</span>
<span className="text-xs text-muted-foreground ml-2">
{item.changeCount} {item.changeCount === 1 ? 'change' : 'changes'}
</span>
</div>
))}
</div>
</div>
<Separator />
{/* Recent Commits */}
<div>
<p className="text-sm font-medium mb-3">Recent Commits (Last 20)</p>
<div className="space-y-3 max-h-96 overflow-y-auto">
{report.gitHistory.commits.slice(0, 20).map((commit, index) => (
<div key={index} className="border-l-2 border-primary/20 pl-3 py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{commit.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{commit.author} {formatDate(commit.date)}
<span className="font-mono ml-1">{commit.hash}</span>
</p>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
<span className="text-green-600">+{commit.insertions}</span> /
<span className="text-red-600">-{commit.deletions}</span>
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Cost Analysis Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<DollarSign className="mr-2 h-5 w-5" />
AI Cost Analysis
</CardTitle>
<CardDescription>
Estimated costs based on {report.costs.model} usage
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium mb-3">Message Statistics</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Total messages:</span>
<span className="font-mono">{formatNumber(report.costs.messageStats.totalMessages)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">User messages:</span>
<span className="font-mono">{formatNumber(report.costs.messageStats.userMessages)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">AI messages:</span>
<span className="font-mono">{formatNumber(report.costs.messageStats.aiMessages)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg length:</span>
<span className="font-mono">{report.costs.messageStats.avgMessageLength} chars</span>
</div>
</div>
</div>
<div>
<p className="text-sm font-medium mb-3">Token Usage</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Input tokens:</span>
<span className="font-mono">{formatNumber(report.costs.estimatedTokens.input)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Output tokens:</span>
<span className="font-mono">{formatNumber(report.costs.estimatedTokens.output)}</span>
</div>
<Separator className="my-2" />
<div className="flex justify-between">
<span className="text-muted-foreground">Total tokens:</span>
<span className="font-mono">{formatNumber(report.costs.estimatedTokens.total)}</span>
</div>
</div>
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-3">Cost Breakdown</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
Input cost ({formatCurrency(report.costs.pricing.inputPer1M)}/1M tokens):
</span>
<span className="font-mono">{formatCurrency(report.costs.costs.inputCost)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
Output cost ({formatCurrency(report.costs.pricing.outputPer1M)}/1M tokens):
</span>
<span className="font-mono">{formatCurrency(report.costs.costs.outputCost)}</span>
</div>
</div>
</div>
<Separator />
<div className="bg-primary/5 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Total AI Cost</p>
<p className="text-xs text-muted-foreground mt-1">{report.costs.model}</p>
</div>
<div className="text-3xl font-bold">{formatCurrency(report.costs.costs.totalCost)}</div>
</div>
</div>
<div className="text-xs text-muted-foreground">
<p>* Token estimation: ~4 characters per token</p>
<p className="mt-1">* Costs are estimates based on message content length</p>
</div>
</CardContent>
</Card>
{/* Features Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Code className="mr-2 h-5 w-5" />
Features Implemented
</CardTitle>
<CardDescription>
Current project capabilities and status
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{report.features.map((feature, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{feature.name}</h3>
<span className={`text-xs px-2 py-1 rounded-full ${
feature.status === 'complete' ? 'bg-green-100 text-green-800' :
feature.status === 'in-progress' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{feature.status}
</span>
</div>
<p className="text-sm text-muted-foreground mb-3">{feature.description}</p>
<div className="grid gap-2 text-xs">
{feature.pages.length > 0 && (
<div>
<span className="font-medium">Pages:</span>{' '}
<span className="text-muted-foreground">{feature.pages.join(', ')}</span>
</div>
)}
{feature.apis.length > 0 && (
<div>
<span className="font-medium">APIs:</span>{' '}
<span className="text-muted-foreground font-mono">{feature.apis.join(', ')}</span>
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Tech Stack Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<TrendingUp className="mr-2 h-5 w-5" />
Technology Stack
</CardTitle>
<CardDescription>
Frameworks, libraries, and integrations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Frontend</p>
<div className="grid gap-2 text-sm">
{Object.entries(report.techStack.frontend).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-muted-foreground capitalize">{key.replace(/([A-Z])/g, ' $1')}:</span>
<span className="font-mono">{value}</span>
</div>
))}
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Backend</p>
<div className="grid gap-2 text-sm">
{Object.entries(report.techStack.backend).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-muted-foreground capitalize">{key}:</span>
<span className="font-mono">{value}</span>
</div>
))}
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Integrations</p>
<div className="flex flex-wrap gap-2">
{report.techStack.integrations.map((integration) => (
<span key={integration} className="text-xs px-2 py-1 bg-secondary rounded-md">
{integration}
</span>
))}
</div>
</div>
</CardContent>
</Card>
<div className="text-xs text-muted-foreground text-center">
Report generated at {new Date(report.generatedAt).toLocaleString()}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,69 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Zap } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
// Mock project data
const MOCK_PROJECT = {
id: "1",
name: "AI Proxy",
emoji: "🤖",
};
interface PageProps {
params: Promise<{ projectId: string }>;
}
export default async function AutomationPage({ params }: PageProps) {
const { projectId } = await params;
return (
<>
<PageHeader
projectId={projectId}
projectName={MOCK_PROJECT.name}
projectEmoji={MOCK_PROJECT.emoji}
pageName="Automation"
/>
<div className="flex-1 overflow-auto">
<div className="container max-w-7xl py-6 space-y-6">
{/* Hero Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Zap className="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>Automation</CardTitle>
<CardDescription>
Create workflows, set up triggers, and automate repetitive tasks
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 rounded-full bg-muted p-4">
<Zap className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-medium text-lg mb-2">Coming Soon</h3>
<p className="text-sm text-muted-foreground max-w-md">
Build custom workflows to automate testing, deployment, notifications,
and other development tasks to accelerate your workflow.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,447 +0,0 @@
"use client";
import type { JSX } from "react";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Code2,
FolderOpen,
File,
ChevronRight,
ChevronDown,
Search,
Loader2,
Github,
RefreshCw,
FileCode
} from "lucide-react";
import { auth } from "@/lib/firebase/config";
import { db } from "@/lib/firebase/config";
import { doc, getDoc } from "firebase/firestore";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface Project {
githubRepo?: string;
githubRepoUrl?: string;
githubDefaultBranch?: string;
}
interface FileNode {
path: string;
name: string;
type: 'file' | 'folder';
children?: FileNode[];
size?: number;
sha?: string;
}
interface GitHubFile {
path: string;
sha: string;
size: number;
url: string;
}
export default function CodePage() {
const params = useParams();
const projectId = params.projectId as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [loadingFiles, setLoadingFiles] = useState(false);
const [fileTree, setFileTree] = useState<FileNode[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
fetchProject();
}, [projectId]);
const fetchProject = async () => {
try {
const projectRef = doc(db, "projects", projectId);
const projectSnap = await getDoc(projectRef);
if (projectSnap.exists()) {
const projectData = projectSnap.data() as Project;
setProject(projectData);
// Auto-load files if GitHub is connected
if (projectData.githubRepo) {
await fetchFileTree(projectData.githubRepo, projectData.githubDefaultBranch);
}
}
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Failed to load project");
} finally {
setLoading(false);
}
};
const fetchFileTree = async (repoFullName: string, branch = 'main') => {
setLoadingFiles(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in");
return;
}
const token = await user.getIdToken();
const [owner, repo] = repoFullName.split('/');
const response = await fetch(
`/api/github/repo-tree?owner=${owner}&repo=${repo}&branch=${branch}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to fetch repository files");
}
const data = await response.json();
const tree = buildFileTree(data.files);
setFileTree(tree);
toast.success(`Loaded ${data.totalFiles} files from ${repoFullName}`);
} catch (error) {
console.error("Error fetching file tree:", error);
toast.error("Failed to load repository files");
} finally {
setLoadingFiles(false);
}
};
const buildFileTree = (files: GitHubFile[]): FileNode[] => {
const root: FileNode = {
path: '/',
name: '/',
type: 'folder',
children: [],
};
files.forEach((file) => {
const parts = file.path.split('/');
let currentNode = root;
parts.forEach((part, index) => {
const isFile = index === parts.length - 1;
const fullPath = parts.slice(0, index + 1).join('/');
if (!currentNode.children) {
currentNode.children = [];
}
let childNode = currentNode.children.find(child => child.name === part);
if (!childNode) {
childNode = {
path: fullPath,
name: part,
type: isFile ? 'file' : 'folder',
...(isFile && { size: file.size, sha: file.sha }),
...(!isFile && { children: [] }),
};
currentNode.children.push(childNode);
}
if (!isFile) {
currentNode = childNode;
}
});
});
// Sort children recursively
const sortNodes = (nodes: FileNode[]) => {
nodes.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
nodes.forEach(node => {
if (node.children) {
sortNodes(node.children);
}
});
};
if (root.children) {
sortNodes(root.children);
}
return root.children || [];
};
const fetchFileContent = async (filePath: string) => {
if (!project?.githubRepo) return;
setLoadingContent(true);
setSelectedFile(filePath);
setFileContent(null);
try {
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in");
return;
}
const token = await user.getIdToken();
const [owner, repo] = project.githubRepo.split('/');
const branch = project.githubDefaultBranch || 'main';
console.log('[Code Page] Fetching file:', filePath);
const response = await fetch(
`/api/github/file-content?owner=${owner}&repo=${repo}&path=${encodeURIComponent(filePath)}&branch=${branch}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[Code Page] Failed to fetch file:', errorData);
throw new Error(errorData.error || "Failed to fetch file content");
}
const data = await response.json();
console.log('[Code Page] File loaded:', data.name, `(${data.size} bytes)`);
setFileContent(data.content);
} catch (error) {
console.error("Error fetching file content:", error);
toast.error(error instanceof Error ? error.message : "Failed to load file content");
setFileContent(`// Error loading file: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setLoadingContent(false);
}
};
const toggleFolder = (path: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedFolders(newExpanded);
};
const renderFileTree = (nodes: FileNode[], level = 0): JSX.Element[] => {
return nodes
.filter(node => {
if (!searchQuery) return true;
return node.name.toLowerCase().includes(searchQuery.toLowerCase());
})
.map((node) => (
<div key={node.path}>
<button
onClick={() => {
if (node.type === 'folder') {
toggleFolder(node.path);
} else {
fetchFileContent(node.path);
}
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors",
selectedFile === node.path && "bg-muted"
)}
style={{ paddingLeft: `${level * 12 + 8}px` }}
>
{node.type === 'folder' ? (
<>
{expandedFolders.has(node.path) ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
</>
) : (
<>
<div className="w-4" />
<FileCode className="h-4 w-4 shrink-0 text-muted-foreground" />
</>
)}
<span className="truncate">{node.name}</span>
{node.size && (
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{formatFileSize(node.size)}
</span>
)}
</button>
{node.type === 'folder' && expandedFolders.has(node.path) && node.children && (
renderFileTree(node.children, level + 1)
)}
</div>
));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!project?.githubRepo) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center gap-2 px-6">
<Code2 className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Code</h1>
</div>
</div>
<div className="flex-1 overflow-auto p-6">
<Card className="max-w-2xl mx-auto p-8 text-center">
<div className="mb-4 rounded-full bg-muted p-4 w-fit mx-auto">
<Github className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-semibold text-lg mb-2">No Repository Connected</h3>
<p className="text-sm text-muted-foreground mb-4">
Connect a GitHub repository in the Context section to view your code here
</p>
<Button onClick={() => window.location.href = `/${params.workspace}/project/${projectId}/context`}>
<Github className="h-4 w-4 mr-2" />
Connect Repository
</Button>
</Card>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center gap-2 px-6">
<Code2 className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Code</h1>
<div className="ml-auto flex items-center gap-2">
<a
href={project.githubRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Github className="h-4 w-4" />
{project.githubRepo}
</a>
<Button
size="sm"
variant="outline"
onClick={() => fetchFileTree(project.githubRepo!, project.githubDefaultBranch)}
disabled={loadingFiles}
>
{loadingFiles ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 flex overflow-hidden">
{/* File Tree Sidebar */}
<div className="w-80 border-r flex flex-col bg-background">
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex-1 overflow-auto p-2">
{loadingFiles ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : fileTree.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No files found
</div>
) : (
renderFileTree(fileTree)
)}
</div>
</div>
{/* Code Viewer */}
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
{selectedFile ? (
<>
<div className="px-4 py-2 border-b bg-background flex items-center gap-2">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-mono">{selectedFile}</span>
</div>
<div className="flex-1 overflow-auto bg-background">
{loadingContent ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : fileContent ? (
<div className="flex">
{/* Line Numbers */}
<div className="select-none border-r bg-muted/30 px-4 py-4 text-right text-sm font-mono text-muted-foreground">
{fileContent.split('\n').map((_, i) => (
<div key={i} className="leading-relaxed">
{i + 1}
</div>
))}
</div>
{/* Code Content */}
<pre className="flex-1 p-4 text-sm font-mono leading-relaxed overflow-x-auto">
<code>{fileContent}</code>
</pre>
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-sm">Failed to load file content</p>
</div>
)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<Code2 className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Select a file to view its contents</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,590 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { FolderOpen, Plus, Github, Zap, FileText, Trash2, CheckCircle2, Upload } from "lucide-react";
import { CursorIcon } from "@/components/icons/custom-icons";
import { db } from "@/lib/firebase/config";
import { collection, doc, getDoc, addDoc, deleteDoc, query, where, getDocs, updateDoc } from "firebase/firestore";
import { toast } from "sonner";
import { auth } from "@/lib/firebase/config";
import { GitHubRepoPicker } from "@/components/ai/github-repo-picker";
interface ContextSource {
id: string;
type: "github" | "extension" | "chat" | "file" | "document";
name: string;
content?: string;
url?: string;
summary?: string;
connectedAt: Date;
metadata?: any;
chunkCount?: number;
}
interface Project {
githubRepo?: string;
githubRepoUrl?: string;
}
export default function ContextPage() {
const params = useParams();
const projectId = params.projectId as string;
const [sources, setSources] = useState<ContextSource[]>([]);
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [chatTitle, setChatTitle] = useState("");
const [chatContent, setChatContent] = useState("");
const [saving, setSaving] = useState(false);
const [uploadMode, setUploadMode] = useState<"text" | "file">("text");
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isGithubDialogOpen, setIsGithubDialogOpen] = useState(false);
useEffect(() => {
const fetchData = async () => {
if (!projectId) return;
try {
// Fetch project details
const projectRef = doc(db, "projects", projectId);
const projectSnap = await getDoc(projectRef);
if (projectSnap.exists()) {
setProject(projectSnap.data() as Project);
}
// Fetch context sources
const contextRef = collection(db, "projects", projectId, "contextSources");
const contextSnap = await getDocs(contextRef);
const fetchedSources: ContextSource[] = contextSnap.docs.map(doc => ({
id: doc.id,
...doc.data(),
connectedAt: doc.data().connectedAt?.toDate() || new Date()
} as ContextSource));
setSources(fetchedSources);
} catch (error) {
console.error("Error fetching context data:", error);
toast.error("Failed to load context sources");
} finally {
setLoading(false);
}
};
fetchData();
}, [projectId]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setSelectedFiles(Array.from(e.target.files));
}
};
const handleAddChatContent = async () => {
if (!chatTitle.trim() || !chatContent.trim()) {
toast.error("Please provide both a title and content");
return;
}
setSaving(true);
try {
// Generate AI summary
toast.info("Generating summary...");
const summaryResponse = await fetch("/api/context/summarize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: chatContent, title: chatTitle })
});
let summary = "";
if (summaryResponse.ok) {
const data = await summaryResponse.json();
summary = data.summary;
} else {
console.error("Failed to generate summary");
summary = `${chatContent.substring(0, 100)}...`;
}
// Also create a knowledge_item so it's included in extraction and checklist
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in");
return;
}
const token = await user.getIdToken();
const importResponse = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
title: chatTitle,
transcript: chatContent, // API expects 'transcript' not 'content'
provider: 'other',
}),
});
if (!importResponse.ok) {
throw new Error("Failed to save content as knowledge item");
}
const contextRef = collection(db, "projects", projectId, "contextSources");
const newSource = {
type: "chat",
name: chatTitle,
content: chatContent,
summary: summary,
connectedAt: new Date(),
metadata: {
length: chatContent.length,
addedManually: true
}
};
const docRef = await addDoc(contextRef, newSource);
setSources([...sources, {
id: docRef.id,
...newSource,
connectedAt: new Date()
} as ContextSource]);
toast.success("Chat content added successfully");
setIsAddModalOpen(false);
setChatTitle("");
setChatContent("");
} catch (error) {
console.error("Error adding chat content:", error);
toast.error("Failed to add chat content");
} finally {
setSaving(false);
}
};
const handleUploadDocuments = async () => {
if (selectedFiles.length === 0) {
toast.error("Please select at least one file");
return;
}
setIsProcessing(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error("Please sign in to upload documents");
return;
}
const token = await user.getIdToken();
for (const file of selectedFiles) {
toast.info(`Uploading ${file.name}...`);
// Create FormData to send file as multipart/form-data
const formData = new FormData();
formData.append('file', file);
formData.append('projectId', projectId);
// Upload to endpoint that handles file storage + chunking
const response = await fetch(`/api/projects/${projectId}/knowledge/upload-document`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload ${file.name}`);
}
const result = await response.json();
toast.success(`${file.name} uploaded: ${result.chunkCount} chunks created`);
}
// Reload sources
const contextRef = collection(db, "projects", projectId, "contextSources");
const contextSnap = await getDocs(contextRef);
const fetchedSources: ContextSource[] = contextSnap.docs.map(doc => ({
id: doc.id,
...doc.data(),
connectedAt: doc.data().connectedAt?.toDate() || new Date()
} as ContextSource));
setSources(fetchedSources);
setIsAddModalOpen(false);
setSelectedFiles([]);
toast.success("All documents uploaded successfully");
} catch (error) {
console.error("Error uploading documents:", error);
toast.error(error instanceof Error ? error.message : "Failed to upload documents");
} finally {
setIsProcessing(false);
}
};
const handleDeleteSource = async (sourceId: string) => {
try {
const sourceRef = doc(db, "projects", projectId, "contextSources", sourceId);
await deleteDoc(sourceRef);
setSources(sources.filter(s => s.id !== sourceId));
toast.success("Context source removed");
} catch (error) {
console.error("Error deleting source:", error);
toast.error("Failed to remove source");
}
};
const getSourceIcon = (type: string) => {
switch (type) {
case "github":
return <Github className="h-5 w-5" />;
case "extension":
return <CursorIcon className="h-5 w-5" />;
case "chat":
return <FileText className="h-5 w-5" />;
case "file":
return <FileText className="h-5 w-5" />;
case "document":
return <FileText className="h-5 w-5" />;
default:
return <FolderOpen className="h-5 w-5" />;
}
};
const getSourceLabel = (source: ContextSource) => {
switch (source.type) {
case "github":
return `Connected GitHub: ${source.name}`;
case "extension":
return "Installed Vibn Extension";
case "chat":
return source.name;
case "file":
return source.name;
default:
return source.name;
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-muted-foreground">Loading context sources...</div>
</div>
);
}
// Build sources list with auto-detected connections
// Note: GitHub is now shown in its own section via GitHubRepoPicker component
const allSources: ContextSource[] = [...sources];
// Check if extension is installed (placeholder for now)
const extensionInstalled = true; // TODO: Detect extension
if (extensionInstalled && !sources.find(s => s.type === "extension")) {
allSources.unshift({
id: "extension-auto",
type: "extension",
name: "Cursor Extension",
connectedAt: new Date()
});
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center gap-2 px-6">
<FolderOpen className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Context Sources</h1>
<div className="ml-auto">
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Context
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Context</DialogTitle>
<DialogDescription>
Upload documents or paste text to give the AI more context about your project.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Mode Selector */}
<div className="flex gap-2 p-1 bg-muted rounded-lg">
<Button
variant={uploadMode === "file" ? "secondary" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setUploadMode("file")}
>
<Upload className="h-4 w-4 mr-2" />
Upload Files
</Button>
<Button
variant={uploadMode === "text" ? "secondary" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setUploadMode("text")}
>
<FileText className="h-4 w-4 mr-2" />
Paste Text
</Button>
</div>
{uploadMode === "file" ? (
/* File Upload Mode */
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file-upload">Select Documents</Label>
<Input
id="file-upload"
type="file"
multiple
accept=".txt,.md,.pdf,.doc,.docx,.json,.csv,.xml"
onChange={handleFileChange}
/>
{selectedFiles.length > 0 && (
<div className="text-sm text-muted-foreground mt-2">
Selected: {selectedFiles.map(f => f.name).join(", ")}
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
Documents will be stored for the Extractor AI to review and process.
Supported formats: TXT, MD, PDF, DOC, JSON, CSV, XML
</p>
</div>
) : (
/* Text Paste Mode */
<>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="e.g., Planning discussion with Sarah"
value={chatTitle}
onChange={(e) => setChatTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
placeholder="Paste your chat conversation or notes here..."
value={chatContent}
onChange={(e) => setChatContent(e.target.value)}
className="min-h-[300px] font-mono text-sm"
/>
</div>
</>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
Cancel
</Button>
{uploadMode === "file" ? (
<Button onClick={handleUploadDocuments} disabled={isProcessing || selectedFiles.length === 0}>
{isProcessing ? "Processing..." : `Upload ${selectedFiles.length} File${selectedFiles.length !== 1 ? 's' : ''}`}
</Button>
) : (
<Button onClick={handleAddChatContent} disabled={saving}>
{saving ? "Saving..." : "Add Context"}
</Button>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-4xl space-y-4">
{/* GitHub Repository Connection */}
<div className="mb-6">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
GitHub Repository
</h2>
{project?.githubRepo ? (
// Show connected repo
<Card className="p-4">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Github className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-sm">Connected: {project.githubRepo}</h3>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</div>
<p className="text-xs text-muted-foreground mb-2">
Repository connected and ready for AI access
</p>
{project.githubRepoUrl && (
<a
href={project.githubRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline inline-block"
>
View on GitHub
</a>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsGithubDialogOpen(true)}
>
Change
</Button>
</div>
</Card>
) : (
// Show connect button
<Card className="p-4">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Github className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm mb-1">Connect GitHub Repository</h3>
<p className="text-xs text-muted-foreground">
Give the AI access to your codebase for better context
</p>
</div>
<Button
onClick={() => setIsGithubDialogOpen(true)}
size="sm"
>
<Github className="h-4 w-4 mr-2" />
Connect
</Button>
</div>
</Card>
)}
{/* GitHub Connection Dialog */}
<Dialog open={isGithubDialogOpen} onOpenChange={setIsGithubDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Connect GitHub Repository</DialogTitle>
<DialogDescription>
Connect a GitHub repository to give the AI access to your codebase
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto">
<GitHubRepoPicker
projectId={projectId}
onRepoSelected={(repo) => {
toast.success(`Repository ${repo.full_name} connected!`);
setIsGithubDialogOpen(false);
// Reload project data to show the connected repo
const fetchProject = async () => {
const projectRef = doc(db, "projects", projectId);
const projectSnap = await getDoc(projectRef);
if (projectSnap.exists()) {
setProject(projectSnap.data() as Project);
}
};
fetchProject();
}}
/>
</div>
</DialogContent>
</Dialog>
</div>
{/* Other Context Sources */}
<div>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Additional Context
</h2>
</div>
{allSources.length === 0 ? (
<Card className="p-8 text-center">
<FolderOpen className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">No Context Sources Yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Add context sources to help the AI understand your project better
</p>
<Button onClick={() => setIsAddModalOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Context
</Button>
</Card>
) : (
<div className="space-y-3">
{allSources.map((source) => (
<Card key={source.id} className="p-4">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
{getSourceIcon(source.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-sm">{getSourceLabel(source)}</h3>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</div>
<p className="text-xs text-muted-foreground">
Connected {source.connectedAt.toLocaleDateString()}
</p>
{source.summary && (
<p className="text-sm text-foreground/80 mt-2 leading-relaxed">
{source.summary}
</p>
)}
{source.url && (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline mt-1 inline-block"
>
{source.type === 'github' ? 'View on GitHub →' :
source.type === 'document' ? 'Download File →' :
'View Source →'}
</a>
)}
</div>
{!source.id.includes("auto") && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSource(source.id)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
</Card>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,69 +1,204 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Server } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
"use client";
// Mock project data
const MOCK_PROJECT = {
id: "1",
name: "AI Proxy",
emoji: "🤖",
};
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { toast } from "sonner";
interface PageProps {
params: Promise<{ projectId: string }>;
interface Project {
id: string;
productName: string;
status?: string;
giteaRepoUrl?: string;
giteaRepo?: string;
theiaWorkspaceUrl?: string;
coolifyDeployUrl?: string;
customDomain?: string;
prd?: string;
}
export default async function DeploymentPage({ params }: PageProps) {
const { projectId } = await params;
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<>
<PageHeader
projectId={projectId}
projectName={MOCK_PROJECT.name}
projectEmoji={MOCK_PROJECT.emoji}
pageName="Deployment"
/>
<div className="flex-1 overflow-auto">
<div className="container max-w-7xl py-6 space-y-6">
{/* Hero Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>Deployment</CardTitle>
<CardDescription>
Manage deployments, monitor environments, and track releases
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 rounded-full bg-muted p-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-medium text-lg mb-2">Coming Soon</h3>
<p className="text-sm text-muted-foreground max-w-md">
Connect your hosting platforms to manage deployments, view logs,
and monitor your application&apos;s health across all environments.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</>
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
}}>
{children}
</div>
);
}
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
boxShadow: "0 1px 2px #1a1a1a05", marginBottom: 12, ...style,
}}>
{children}
</div>
);
}
export default function DeploymentPage() {
const params = useParams();
const projectId = params.projectId as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [customDomainInput, setCustomDomainInput] = useState("");
const [connecting, setConnecting] = useState(false);
useEffect(() => {
fetch(`/api/projects/${projectId}`)
.then((r) => r.json())
.then((d) => setProject(d.project))
.catch(() => {})
.finally(() => setLoading(false));
}, [projectId]);
const handleConnectDomain = async () => {
if (!customDomainInput.trim()) return;
setConnecting(true);
await new Promise((r) => setTimeout(r, 800));
toast.info("Domain connection coming soon — we'll hook this to Coolify.");
setConnecting(false);
};
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
const hasRepo = Boolean(project?.giteaRepoUrl);
const hasPRD = Boolean(project?.prd);
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 560 }}>
<h3 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
}}>
Deployment
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
Links, environments, and hosting for {project?.productName ?? "this project"}
</p>
{/* Project URLs */}
<InfoCard style={{ padding: "22px" }}>
<SectionLabel>Project URLs</SectionLabel>
{hasDeploy ? (
<>
{project?.coolifyDeployUrl && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Staging</div>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
</div>
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Open
</a>
</div>
)}
{project?.customDomain && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#2e7d3210", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#2e7d32" }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Production</div>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#2e7d32", fontWeight: 500 }}>{project.customDomain}</div>
</div>
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Open
</a>
</div>
)}
{project?.giteaRepoUrl && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Build repo</div>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
</div>
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
View
</a>
</div>
)}
</>
) : (
<div style={{ padding: "18px 0", textAlign: "center" }}>
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
{!hasPRD
? "Complete your PRD with Vibn first, then build and deploy."
: !hasRepo
? "No repository yet — the Architect agent will scaffold one from your PRD."
: "No deployment yet — kick off a build to get a live URL."}
</p>
</div>
)}
</InfoCard>
{/* Custom domain */}
{hasDeploy && !project?.customDomain && (
<InfoCard style={{ padding: "22px" }}>
<SectionLabel>Custom Domain</SectionLabel>
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.6, marginBottom: 14 }}>
Point your own domain to this project. SSL certificates are handled automatically.
</p>
<div style={{ display: "flex", gap: 8 }}>
<input
placeholder="app.yourdomain.com"
value={customDomainInput}
onChange={(e) => setCustomDomainInput(e.target.value)}
style={{ flex: 1, padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a" }}
/>
<button
onClick={handleConnectDomain}
disabled={connecting}
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", opacity: connecting ? 0.6 : 1 }}
>
{connecting ? "Connecting…" : "Connect"}
</button>
</div>
</InfoCard>
)}
{/* Environment variables */}
<InfoCard style={{ padding: "22px" }}>
<SectionLabel>Environment Variables</SectionLabel>
{hasDeploy ? (
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
Manage environment variables in Coolify for your deployed services.
{project?.coolifyDeployUrl && (
<> <a href="http://34.19.250.135:8000" target="_blank" rel="noopener noreferrer" style={{ color: "#3d5afe", textDecoration: "none" }}>Open Coolify </a></>
)}
</p>
) : (
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>Available after first build completes.</p>
)}
</InfoCard>
{/* Deploy history */}
<InfoCard style={{ padding: "22px" }}>
<SectionLabel>Deploy History</SectionLabel>
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
{project?.status === "live"
? "Deploy history will appear here."
: "No deploys yet."}
</p>
</InfoCard>
</div>
</div>
);
}

View File

@@ -1,633 +0,0 @@
"use client";
import { use, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Eye, MessageSquare, Copy, Share2, Sparkles, History, Loader2, Send, MousePointer2 } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
// Mock data for page variations
const mockPageData: Record<string, any> = {
"landing-hero": {
name: "Landing Page Hero",
emoji: "✨",
style: "modern",
prompt: "Create a modern landing page hero section with gradient background",
v0Url: "https://v0.dev/chat/abc123",
variations: [
{
id: 1,
name: "Version 1 - Blue Gradient",
thumbnail: "https://placehold.co/800x600/1e40af/ffffff?text=Hero+V1",
createdAt: "2025-11-11",
views: 45,
comments: 3,
},
{
id: 2,
name: "Version 2 - Purple Gradient",
thumbnail: "https://placehold.co/800x600/7c3aed/ffffff?text=Hero+V2",
createdAt: "2025-11-10",
views: 32,
comments: 2,
},
{
id: 3,
name: "Version 3 - Minimal",
thumbnail: "https://placehold.co/800x600/6b7280/ffffff?text=Hero+V3",
createdAt: "2025-11-09",
views: 28,
comments: 1,
},
],
},
"dashboard": {
name: "Dashboard Layout",
emoji: "📊",
style: "minimal",
prompt: "Design a clean dashboard with sidebar, metrics cards, and charts",
v0Url: "https://v0.dev/chat/def456",
variations: [
{
id: 1,
name: "Version 1 - Default",
thumbnail: "https://placehold.co/800x600/7c3aed/ffffff?text=Dashboard+V1",
createdAt: "2025-11-10",
views: 78,
comments: 8,
},
],
},
"pricing": {
name: "Pricing Cards",
emoji: "💳",
style: "colorful",
prompt: "Three-tier pricing cards with features, hover effects, and CTA buttons",
v0Url: "https://v0.dev/chat/ghi789",
variations: [
{
id: 1,
name: "Version 1 - Standard",
thumbnail: "https://placehold.co/800x600/059669/ffffff?text=Pricing+V1",
createdAt: "2025-11-09",
views: 102,
comments: 12,
},
{
id: 2,
name: "Version 2 - Compact",
thumbnail: "https://placehold.co/800x600/0891b2/ffffff?text=Pricing+V2",
createdAt: "2025-11-08",
views: 67,
comments: 5,
},
],
},
"user-profile": {
name: "User Profile",
emoji: "👤",
style: "modern",
prompt: "User profile page with avatar, bio, stats, and activity feed",
v0Url: "https://v0.dev/chat/jkl012",
variations: [
{
id: 1,
name: "Version 1 - Default",
thumbnail: "https://placehold.co/800x600/dc2626/ffffff?text=Profile+V1",
createdAt: "2025-11-08",
views: 56,
comments: 5,
},
],
},
};
export default function DesignPageView({
params,
}: {
params: Promise<{ projectId: string; pageSlug: string }>;
}) {
const { projectId, pageSlug } = use(params);
const pageData = mockPageData[pageSlug] || mockPageData["landing-hero"];
const [editPrompt, setEditPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [currentVersion, setCurrentVersion] = useState(pageData.variations[0]);
const [versionsModalOpen, setVersionsModalOpen] = useState(false);
const [commentsModalOpen, setCommentsModalOpen] = useState(false);
const [chatMessage, setChatMessage] = useState("");
const [pageName, setPageName] = useState(pageData.name);
const [isEditingName, setIsEditingName] = useState(false);
const [designModeActive, setDesignModeActive] = useState(false);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const handleIterate = async () => {
if (!editPrompt.trim()) {
toast.error("Please enter a prompt to iterate");
return;
}
setIsGenerating(true);
try {
// Call v0 API to generate update
const response = await fetch('/api/v0/iterate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: pageData.v0Url.split('/').pop(),
message: editPrompt,
projectId,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to iterate');
}
toast.success("Design updated!", {
description: "Your changes have been generated",
});
// Refresh or update the current version
setEditPrompt("");
} catch (error) {
console.error('Error iterating:', error);
toast.error(error instanceof Error ? error.message : "Failed to iterate design");
} finally {
setIsGenerating(false);
}
};
const handlePushToCursor = () => {
toast.success("Code will be pushed to Cursor", {
description: "This feature will send the component code to your IDE",
});
// TODO: Implement actual push to Cursor IDE
};
return (
<>
<div className="flex h-full flex-col overflow-hidden">
{/* Toolbar */}
<div className="border-b bg-card/50 px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
{isEditingName ? (
<input
type="text"
value={pageName}
onChange={(e) => setPageName(e.target.value)}
onBlur={() => {
setIsEditingName(false);
toast.success("Page name updated");
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setIsEditingName(false);
toast.success("Page name updated");
}
}}
className="text-lg font-semibold bg-transparent border-b border-primary outline-none px-1 min-w-[200px]"
autoFocus
/>
) : (
<h1
className="text-lg font-semibold cursor-pointer hover:text-primary transition-colors"
onClick={() => setIsEditingName(true)}
>
{pageName}
</h1>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setVersionsModalOpen(true)}
>
<History className="h-4 w-4 mr-2" />
Versions
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCommentsModalOpen(true)}
>
<MessageSquare className="h-4 w-4 mr-2" />
Comments
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePushToCursor}
>
<Send className="h-4 w-4 mr-2" />
Push to Cursor
</Button>
<Button variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
</div>
</div>
{/* Live Preview */}
<div className="flex-1 overflow-auto bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 relative">
<div className="w-full h-full p-8">
{/* Sample SaaS Dashboard Component */}
<div className="mx-auto max-w-7xl space-y-6">
{/* Page Header */}
<div
data-element="page-header"
className={cn(
"flex items-center justify-between transition-all p-2 rounded-lg",
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
selectedElement === "page-header" && "ring-2 ring-primary ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement("page-header");
}
}}
>
<div>
<h1
data-element="page-title"
className={cn(
"text-3xl font-bold transition-all rounded px-1",
designModeActive && "hover:ring-2 hover:ring-primary/50 hover:ring-inset",
selectedElement === "page-title" && "ring-2 ring-primary/50 ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement("page-title");
}
}}
>
Dashboard Overview
</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here's what's happening today.</p>
</div>
<Button
data-element="primary-action-button"
className={cn(
"transition-all",
designModeActive && "hover:ring-2 hover:ring-yellow-400 hover:ring-inset",
selectedElement === "primary-action-button" && "ring-2 ring-yellow-400 ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement("primary-action-button");
}
}}
>
Create New Project
</Button>
</div>
{/* Stats Grid */}
<div
data-element="stats-grid"
className={cn(
"grid md:grid-cols-4 gap-4 transition-all rounded-xl",
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
selectedElement === "stats-grid" && "ring-2 ring-primary ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement("stats-grid");
}
}}
>
{[
{ label: "Total Users", value: "2,847", change: "+12.3%", trend: "up" },
{ label: "Revenue", value: "$45,231", change: "+8.1%", trend: "up" },
{ label: "Active Projects", value: "127", change: "-2.4%", trend: "down" },
{ label: "Conversion Rate", value: "3.24%", change: "+0.8%", trend: "up" },
].map((stat, i) => (
<Card
key={i}
data-element={`stat-card-${i}`}
className={cn(
"transition-all",
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
selectedElement === `stat-card-${i}` && "ring-2 ring-primary ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement(`stat-card-${i}`);
}
}}
>
<CardHeader className="pb-2">
<CardDescription className="text-xs">{stat.label}</CardDescription>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
<span className={cn(
"text-xs font-medium",
stat.trend === "up" ? "text-green-600" : "text-red-600"
)}>
{stat.change}
</span>
</CardHeader>
</Card>
))}
</div>
{/* Data Table */}
<Card
data-element="data-table"
className={cn(
"transition-all",
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
selectedElement === "data-table" && "ring-2 ring-primary ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement("data-table");
}
}}
>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Projects</CardTitle>
<CardDescription>Your team's latest work</CardDescription>
</div>
<Button
variant="outline"
size="sm"
data-element="table-action-button"
className={cn(
"transition-all",
designModeActive && "hover:ring-2 hover:ring-yellow-400 hover:ring-inset",
selectedElement === "table-action-button" && "ring-2 ring-yellow-400 ring-inset"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement("table-action-button");
}
}}
>
View All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ name: "Mobile App Redesign", status: "In Progress", team: "Design Team", updated: "2 hours ago" },
{ name: "API Documentation", status: "Review", team: "Engineering", updated: "5 hours ago" },
{ name: "Marketing Website", status: "Completed", team: "Marketing", updated: "1 day ago" },
{ name: "User Dashboard v2", status: "Planning", team: "Product", updated: "3 days ago" },
].map((project, i) => (
<div
key={i}
data-element={`table-row-${i}`}
className={cn(
"flex items-center justify-between p-3 rounded-lg border transition-all",
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset hover:bg-accent",
selectedElement === `table-row-${i}` && "ring-2 ring-primary ring-inset bg-accent"
)}
onClick={(e) => {
if (designModeActive) {
e.stopPropagation();
setSelectedElement(`table-row-${i}`);
}
}}
>
<div className="flex-1">
<p className="font-medium">{project.name}</p>
<p className="text-sm text-muted-foreground">{project.team}</p>
</div>
<div className="flex items-center gap-4">
<span className={cn(
"text-xs font-medium px-2 py-1 rounded-full",
project.status === "Completed" && "bg-green-100 text-green-700",
project.status === "In Progress" && "bg-blue-100 text-blue-700",
project.status === "Review" && "bg-yellow-100 text-yellow-700",
project.status === "Planning" && "bg-gray-100 text-gray-700"
)}>
{project.status}
</span>
<span className="text-sm text-muted-foreground w-24 text-right">{project.updated}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Floating Chat Interface - v0 Style */}
<div
className="absolute bottom-6 left-1/2 -translate-x-1/2 w-full max-w-3xl px-6"
>
<div className="bg-background/95 backdrop-blur-lg border border-border rounded-2xl shadow-2xl overflow-hidden">
{/* Input Area */}
<div className="p-3 relative">
<Textarea
placeholder="e.g., 'Make the hero section more vibrant', 'Add a call-to-action button', 'Change the color scheme to dark mode'"
value={chatMessage}
onChange={(e) => setChatMessage(e.target.value)}
className="min-h-[60px] resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 text-sm px-1"
disabled={isGenerating}
rows={2}
/>
</div>
{/* Action Bar */}
<div className="px-4 pb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button
variant={designModeActive ? "default" : "ghost"}
size="sm"
onClick={() => {
setDesignModeActive(!designModeActive);
setSelectedElement(null);
}}
>
<MousePointer2 className="h-4 w-4 mr-2" />
Design Mode
</Button>
{selectedElement && (
<div className="flex items-center gap-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">
<MousePointer2 className="h-3 w-3" />
<span className="font-medium">{selectedElement.replace(/-/g, ' ')}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
disabled={isGenerating}
onClick={() => {
toast.info("Creating variation...");
}}
>
<Copy className="h-4 w-4 mr-1" />
Variation
</Button>
<Button
size="sm"
onClick={() => {
const contextualPrompt = selectedElement
? `[Targeting: ${selectedElement.replace(/-/g, ' ')}] ${chatMessage}`
: chatMessage;
setEditPrompt(contextualPrompt);
handleIterate();
}}
disabled={isGenerating || !chatMessage.trim()}
className="gap-2"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating
</>
) : (
<>
<Sparkles className="h-4 w-4" />
{selectedElement ? 'Modify Selected' : 'Generate'}
</>
)}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Versions Modal */}
<Dialog open={versionsModalOpen} onOpenChange={setVersionsModalOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Version History</DialogTitle>
<DialogDescription>
View and switch between different versions of this design
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-4">
<div className="space-y-3">
{pageData.variations.map((variation: any) => (
<button
key={variation.id}
onClick={() => {
setCurrentVersion(variation);
setVersionsModalOpen(false);
toast.success(`Switched to ${variation.name}`);
}}
className={`w-full text-left rounded-lg border p-4 transition-colors hover:bg-accent ${
currentVersion.id === variation.id ? 'border-primary bg-accent' : ''
}`}
>
<div className="flex items-start gap-4">
<img
src={variation.thumbnail}
alt={variation.name}
className="w-32 h-20 rounded object-cover"
/>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-base">{variation.name}</h4>
<p className="text-sm text-muted-foreground mt-1">
{variation.createdAt}
</p>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="h-4 w-4" />
{variation.views} views
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-4 w-4" />
{variation.comments} comments
</span>
</div>
</div>
</div>
</button>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
{/* Comments Modal */}
<Dialog open={commentsModalOpen} onOpenChange={setCommentsModalOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Comments & Feedback</DialogTitle>
<DialogDescription>
Discuss this design with your team
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[50vh] pr-4">
<div className="space-y-4">
{/* Mock comments */}
<div className="space-y-3">
<div className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium">
JD
</div>
<div className="flex-1">
<span className="text-sm font-medium">Jane Doe</span>
<span className="text-xs text-muted-foreground ml-2">2h ago</span>
</div>
</div>
<p className="text-sm text-muted-foreground">
Love the gradient! Could we try a darker variant?
</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-green-500/10 flex items-center justify-center text-sm font-medium">
MS
</div>
<div className="flex-1">
<span className="text-sm font-medium">Mike Smith</span>
<span className="text-xs text-muted-foreground ml-2">5h ago</span>
</div>
</div>
<p className="text-sm text-muted-foreground">
The layout looks perfect. Spacing is on point 👍
</p>
</div>
</div>
</div>
</ScrollArea>
{/* Add comment */}
<div className="pt-4 border-t space-y-3">
<Textarea
placeholder="Add a comment..."
className="min-h-[100px] resize-none"
/>
<Button className="w-full">
<MessageSquare className="h-4 w-4 mr-2" />
Post Comment
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,558 +0,0 @@
"use client";
import { use, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, ChevronRight, ChevronDown, Folder, FileText, Palette, LayoutGrid, Workflow, Github, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import { usePathname } from "next/navigation";
import {
PageTemplate,
PageSection,
PageCard as TemplateCard,
} from "@/components/layout/page-template";
// Mock tree structure - Core Product screens
const coreProductTree = [
{
id: "dashboard",
name: "Dashboard",
type: "folder",
children: [
{ id: "overview", name: "Overview", type: "page", route: "/dashboard", variations: 2 },
{ id: "analytics", name: "Analytics", type: "page", route: "/dashboard/analytics", variations: 1 },
{ id: "projects", name: "Projects", type: "page", route: "/dashboard/projects", variations: 2 },
{ id: "activity", name: "Activity", type: "page", route: "/dashboard/activity", variations: 1 },
],
},
{
id: "profile",
name: "Profile & Settings",
type: "folder",
children: [
{ id: "user-profile", name: "User Profile", type: "page", route: "/profile", variations: 2 },
{ id: "edit-profile", name: "Edit Profile", type: "page", route: "/profile/edit", variations: 1 },
{ id: "account", name: "Account Settings", type: "page", route: "/settings/account", variations: 1 },
{ id: "billing", name: "Billing", type: "page", route: "/settings/billing", variations: 2 },
{ id: "notifications", name: "Notifications", type: "page", route: "/settings/notifications", variations: 1 },
],
},
];
// AI-suggested screens for Core Product
const suggestedCoreScreens = [
{
id: "team-management",
name: "Team Management",
reason: "Collaborate with team members and manage permissions",
version: "V1",
},
{
id: "reports",
name: "Reports & Insights",
reason: "Data-driven decision making with comprehensive reports",
version: "V2",
},
{
id: "integrations",
name: "Integrations",
reason: "Connect with external tools and services",
version: "V2",
},
{
id: "search",
name: "Global Search",
reason: "Quick access to any content across the platform",
version: "V2",
},
{
id: "empty-states",
name: "Empty States",
reason: "Guide users when no data is available",
version: "V1",
},
];
// Mock tree structure - User Flows
const userFlowsTree = [
{
id: "authentication",
name: "Authentication",
type: "folder",
children: [
{ id: "signup", name: "Sign Up", type: "page", route: "/signup", variations: 3 },
{ id: "login", name: "Login", type: "page", route: "/login", variations: 2 },
{ id: "forgot-password", name: "Forgot Password", type: "page", route: "/forgot-password", variations: 1 },
{ id: "verify-email", name: "Verify Email", type: "page", route: "/verify-email", variations: 1 },
],
},
{
id: "onboarding",
name: "Onboarding",
type: "folder",
children: [
{ id: "welcome", name: "Welcome", type: "page", route: "/onboarding/welcome", variations: 2 },
{ id: "setup-profile", name: "Setup Profile", type: "page", route: "/onboarding/profile", variations: 2 },
{ id: "preferences", name: "Preferences", type: "page", route: "/onboarding/preferences", variations: 1 },
{ id: "complete", name: "Complete", type: "page", route: "/onboarding/complete", variations: 1 },
],
},
];
// AI-suggested flows/screens
const suggestedFlows = [
{
id: "password-reset",
name: "Password Reset Flow",
reason: "Users need a complete password reset journey",
version: "V1",
screens: [
{ name: "Reset Request" },
{ name: "Check Email" },
{ name: "New Password" },
{ name: "Success" },
],
},
{
id: "email-verification",
name: "Email Verification Flow",
reason: "Enhance security with multi-step verification",
version: "V2",
screens: [
{ name: "Verification Sent" },
{ name: "Enter Code" },
{ name: "Verified" },
],
},
{
id: "two-factor-setup",
name: "Two-Factor Auth Setup",
reason: "Add additional security layer for users",
version: "V2",
screens: [
{ name: "Enable 2FA" },
{ name: "Setup Authenticator" },
{ name: "Verify Code" },
{ name: "Backup Codes" },
],
},
];
const DESIGN_NAV_ITEMS = [
{ title: "Core Screens", icon: LayoutGrid, href: "#screens" },
{ title: "User Flows", icon: Workflow, href: "#flows" },
{ title: "Style Guide", icon: Palette, href: "#style-guide" },
];
export default function UIUXPage({ params }: { params: Promise<{ projectId: string }> }) {
const { projectId } = use(params);
const pathname = usePathname();
const workspace = pathname.split('/')[1]; // quick hack to get workspace
const [prompt, setPrompt] = useState("");
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
// GitHub connection state
const [isGithubConnected, setIsGithubConnected] = useState(false);
const [githubRepo, setGithubRepo] = useState<string | null>(null);
const [lastSyncTime, setLastSyncTime] = useState<string | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
// Tree view state
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["authentication", "dashboard"]));
const toggleFolder = (folderId: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId);
} else {
newExpanded.add(folderId);
}
setExpandedFolders(newExpanded);
};
const handleConnectGithub = async () => {
toast.info("Opening GitHub OAuth...");
setTimeout(() => {
setIsGithubConnected(true);
setGithubRepo("username/repo-name");
toast.success("GitHub connected!", {
description: "Click Sync to scan your repository",
});
}, 1500);
};
const handleSyncRepository = async () => {
setIsSyncing(true);
try {
toast.info("Syncing repository...", {
description: "AI is analyzing your codebase",
});
const response = await fetch('/api/github/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
repo: githubRepo,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to sync repository');
}
setLastSyncTime(new Date().toISOString());
toast.success("Repository synced!", {
description: `Found ${data.pageCount} pages`,
});
} catch (error) {
console.error('Error syncing repository:', error);
toast.error(error instanceof Error ? error.message : "Failed to sync repository");
} finally {
setIsSyncing(false);
}
};
const handleGenerate = async () => {
if (!prompt.trim()) {
toast.error("Please enter a design prompt");
return;
}
setIsGenerating(true);
try {
const response = await fetch('/api/v0/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
style: selectedStyle,
projectId,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to generate design');
}
toast.success("Design generated successfully!", {
description: "Opening in v0...",
action: {
label: "View",
onClick: () => window.open(data.webUrl, '_blank'),
},
});
window.open(data.webUrl, '_blank');
setPrompt("");
setSelectedStyle(null);
} catch (error) {
console.error('Error generating design:', error);
toast.error(error instanceof Error ? error.message : "Failed to generate design");
} finally {
setIsGenerating(false);
}
};
const sidebarItems = DESIGN_NAV_ITEMS.map((item) => {
const fullHref = `/${workspace}/project/${projectId}/design${item.href}`;
return {
...item,
href: fullHref,
isActive: pathname === fullHref || pathname.startsWith(fullHref),
};
});
return (
<PageTemplate
sidebar={{
items: sidebarItems,
}}
>
<div className="space-y-8">
{/* GitHub Connection / Sync */}
<div className="flex items-center justify-between p-4 rounded-lg border bg-card">
{!isGithubConnected ? (
<>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Github className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Connect Repository</p>
<p className="text-xs text-muted-foreground">Sync your GitHub repo to detect pages</p>
</div>
</div>
<Button onClick={handleConnectGithub} size="sm">
<Github className="h-4 w-4 mr-2" />
Connect
</Button>
</>
) : (
<>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Github className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">{githubRepo}</p>
{lastSyncTime && (
<p className="text-xs text-muted-foreground">
Synced {new Date(lastSyncTime).toLocaleTimeString()}
</p>
)}
</div>
</div>
<Button
onClick={handleSyncRepository}
disabled={isSyncing}
size="sm"
variant="outline"
>
{isSyncing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Syncing
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Sync
</>
)}
</Button>
</>
)}
</div>
{/* Product Screens - Split into two columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Core Product */}
<Card>
<CardHeader>
<CardTitle>Core Product</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{coreProductTree.map((folder) => (
<div key={folder.id}>
{/* Folder */}
<button
onClick={() => toggleFolder(folder.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm font-medium"
>
{expandedFolders.has(folder.id) ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Folder className="h-4 w-4 text-muted-foreground" />
<span className="font-medium truncate">{folder.name}</span>
<span className="text-xs text-muted-foreground ml-auto">
{folder.children.length}
</span>
</button>
{/* Pages in folder */}
{expandedFolders.has(folder.id) && (
<div className="ml-6 space-y-0.5 mt-0.5">
{folder.children.map((page: any) => (
<button
key={page.id}
className="flex items-center justify-between gap-2 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm group"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{page.name}</span>
</div>
{page.variations > 0 && (
<Badge variant="secondary" className="text-xs shrink-0">
{page.variations}
</Badge>
)}
</button>
))}
</div>
)}
</div>
))}
{/* AI Suggested Screens */}
<Separator className="my-3" />
<div className="space-y-2">
<div className="flex items-center gap-2 px-2">
<Sparkles className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-muted-foreground">AI Suggested</h3>
</div>
{suggestedCoreScreens.map((screen) => (
<div key={screen.id} className="px-3 py-2.5 rounded-md border border-dashed border-primary/30 bg-primary/5 hover:bg-primary/10 transition-colors">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
<div className="font-medium text-sm text-primary truncate">{screen.name}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="secondary" className="text-xs">
{screen.version}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => {
toast.success("Generating screen...", {
description: `Creating ${screen.name}`,
});
}}
>
<Sparkles className="h-3 w-3 mr-1" />
Generate
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* User Flows */}
<Card>
<CardHeader>
<CardTitle>User Flows</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{userFlowsTree.map((folder) => (
<div key={folder.id}>
{/* Folder */}
<button
onClick={() => toggleFolder(folder.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm font-medium"
>
{expandedFolders.has(folder.id) ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Folder className="h-4 w-4 text-muted-foreground" />
<span className="font-medium truncate">{folder.name}</span>
<span className="text-xs text-muted-foreground ml-auto">
{folder.children.length} steps
</span>
</button>
{/* Pages in folder - with flow indicators */}
{expandedFolders.has(folder.id) && (
<div className="ml-6 mt-0.5 space-y-0.5">
{folder.children.map((page: any, index: number) => (
<div key={page.id}>
<button
className="flex items-center justify-between gap-3 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm group"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold shrink-0">
{index + 1}
</div>
<span className="truncate">{page.name}</span>
</div>
{page.variations > 0 && (
<Badge variant="secondary" className="text-xs shrink-0">
{page.variations}
</Badge>
)}
</button>
</div>
))}
</div>
)}
</div>
))}
{/* AI Suggested Flows */}
<Separator className="my-3" />
<div className="space-y-2">
<div className="flex items-center gap-2 px-2">
<Sparkles className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-muted-foreground">AI Suggested</h3>
</div>
{suggestedFlows.map((flow) => (
<div key={flow.id} className="space-y-1">
<button
onClick={() => toggleFolder(`suggested-${flow.id}`)}
className="flex items-center gap-2 w-full px-3 py-2.5 rounded-md border border-dashed border-primary/30 bg-primary/5 hover:bg-primary/10 transition-colors text-sm"
>
{expandedFolders.has(`suggested-${flow.id}`) ? (
<ChevronDown className="h-4 w-4 text-primary" />
) : (
<ChevronRight className="h-4 w-4 text-primary" />
)}
<Sparkles className="h-4 w-4 text-primary" />
<div className="flex-1 text-left min-w-0">
<div className="font-medium text-primary truncate">{flow.name}</div>
</div>
<Badge variant="secondary" className="text-xs shrink-0">
{flow.version}
</Badge>
<span className="text-xs text-primary shrink-0">
{flow.screens.length} screens
</span>
</button>
{/* Suggested screens in flow */}
{expandedFolders.has(`suggested-${flow.id}`) && (
<div className="ml-6 mt-0.5 space-y-0.5">
{flow.screens.map((screen: any, index: number) => (
<div key={index}>
<div className="flex items-center gap-3 px-3 py-1.5 rounded-md border border-dashed text-sm">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-muted text-muted-foreground text-xs font-semibold shrink-0">
{index + 1}
</div>
<div className="font-medium text-sm truncate">{screen.name}</div>
</div>
</div>
))}
{/* Generate button */}
<div className="pt-1.5">
<Button
size="sm"
className="w-full"
onClick={() => {
toast.success("Generating flow...", {
description: `Creating ${flow.screens.length} screens for ${flow.name}`,
});
}}
>
<Sparkles className="h-4 w-4 mr-2" />
Generate This Flow
</Button>
</div>
</div>
)}
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</PageTemplate>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,408 +0,0 @@
"use client";
import { use, useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FileText,
Plus,
Search,
Filter,
MoreHorizontal,
Star,
Info,
Share2,
Archive,
Loader2,
Target,
Lightbulb,
MessageSquare,
BookOpen,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { toast } from "sonner";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
type DocType = "all" | "vision" | "features" | "research" | "chats";
type ViewType = "public" | "private" | "archived";
interface Document {
id: string;
title: string;
type: DocType;
owner: string;
dateModified: string;
visibility: ViewType;
starred: boolean;
chunkCount?: number;
}
export default function DocsPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = use(params);
const [activeView, setActiveView] = useState<ViewType>("public");
const [filterType, setFilterType] = useState<DocType>("all");
const [searchQuery, setSearchQuery] = useState("");
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<"modified" | "created">("modified");
useEffect(() => {
loadDocuments();
}, [projectId, activeView, filterType]);
const loadDocuments = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/projects/${projectId}/knowledge/items?visibility=${activeView}&type=${filterType}`
);
if (response.ok) {
const data = await response.json();
// Use returned items or mock data if empty
if (data.items && data.items.length > 0) {
// Transform knowledge items to document format
const docs = data.items.map((item: any, index: number) => ({
id: item.id,
title: item.title,
type: item.sourceType === 'vision' ? 'vision' :
item.sourceType === 'feature' ? 'features' :
item.sourceType === 'chat' ? 'chats' : 'research',
owner: "You",
dateModified: item.updatedAt || item.createdAt,
visibility: activeView,
starred: false,
chunkCount: item.chunkCount,
}));
setDocuments(docs);
} else {
// Show mock data when no real data exists
setDocuments([
{
id: "1",
title: "Project Vision & Mission",
type: "vision",
owner: "You",
dateModified: new Date().toISOString(),
visibility: "public",
starred: true,
chunkCount: 12,
},
{
id: "2",
title: "Core Features Specification",
type: "features",
owner: "You",
dateModified: new Date(Date.now() - 86400000).toISOString(),
visibility: "public",
starred: false,
chunkCount: 24,
},
]);
}
} else {
// Fallback to mock data on error
setDocuments([
{
id: "1",
title: "Project Vision & Mission",
type: "vision",
owner: "You",
dateModified: new Date().toISOString(),
visibility: "public",
starred: true,
chunkCount: 12,
},
]);
}
} catch (error) {
console.error("Error loading documents:", error);
// Show mock data on error
setDocuments([
{
id: "1",
title: "Project Vision & Mission",
type: "vision",
owner: "You",
dateModified: new Date().toISOString(),
visibility: "public",
starred: true,
chunkCount: 12,
},
]);
} finally {
setLoading(false);
}
};
const getDocIcon = (type: DocType) => {
switch (type) {
case "vision":
return <Target className="h-4 w-4 text-blue-600" />;
case "features":
return <Lightbulb className="h-4 w-4 text-purple-600" />;
case "research":
return <BookOpen className="h-4 w-4 text-green-600" />;
case "chats":
return <MessageSquare className="h-4 w-4 text-orange-600" />;
default:
return <FileText className="h-4 w-4 text-gray-600" />;
}
};
const filteredDocuments = documents.filter((doc) => {
if (searchQuery && !doc.title.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
return true;
});
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Document Stats</h3>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Total Docs</span>
<span className="font-medium">{documents.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Public</span>
<span className="font-medium">{documents.filter(d => d.visibility === 'public').length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Private</span>
<span className="font-medium">{documents.filter(d => d.visibility === 'private').length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Starred</span>
<span className="font-medium">{documents.filter(d => d.starred).length}</span>
</div>
</div>
</div>
</div>
</CollapsibleSidebar>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="border-b bg-background">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold">Docs</h1>
<Badge variant="secondary" className="font-normal">
{filteredDocuments.length} {filteredDocuments.length === 1 ? "doc" : "docs"}
</Badge>
</div>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Add page
</Button>
</div>
{/* Tabs */}
<div className="flex items-center gap-6 px-4">
<button
onClick={() => setActiveView("public")}
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeView === "public"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Public
</button>
<button
onClick={() => setActiveView("private")}
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeView === "private"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Private
</button>
<button
onClick={() => setActiveView("archived")}
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeView === "archived"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Archived
</button>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-4 p-4 border-b bg-muted/30">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search docs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modified">Date modified</SelectItem>
<SelectItem value="created">Date created</SelectItem>
</SelectContent>
</Select>
<Select value={filterType} onValueChange={(value: any) => setFilterType(value)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="vision">Vision</SelectItem>
<SelectItem value="features">Features</SelectItem>
<SelectItem value="research">Research</SelectItem>
<SelectItem value="chats">Chats</SelectItem>
</SelectContent>
</Select>
</div>
{/* Document List */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No documents yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create your first document to get started
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add page
</Button>
</div>
) : (
<div className="space-y-2">
{filteredDocuments.map((doc) => (
<Link
key={doc.id}
href={`/${workspace}/project/${projectId}/docs/${doc.id}`}
className="block"
>
<Card className="p-4 hover:bg-accent/50 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{getDocIcon(doc.type)}
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{doc.title}</h3>
{doc.starred && (
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
)}
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-muted-foreground">{doc.owner}</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
{new Date(doc.dateModified).toLocaleDateString()}
</span>
{doc.chunkCount && (
<>
<span className="text-xs text-muted-foreground"></span>
<Badge variant="secondary" className="text-xs">
{doc.chunkCount} chunks
</Badge>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.preventDefault();
toast.info("Share functionality coming soon");
}}
>
<Share2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.preventDefault();
toast.info("Info panel coming soon");
}}
>
<Info className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.preventDefault();
// Toggle star
}}
>
<Star
className={`h-4 w-4 ${
doc.starred ? "fill-yellow-400 text-yellow-400" : ""
}`}
/>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.preventDefault();
toast.info("More options coming soon");
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
{/* End Main Content */}
</div>
);
}

View File

@@ -1,66 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Box, Plus } from "lucide-react";
export default async function FeaturesPage({
params,
}: {
params: { projectId: string };
}) {
return (
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="border-b bg-card/50 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Features</h1>
<p className="text-sm text-muted-foreground">
Plan and track your product features
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Feature
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl">
<Card>
<CardHeader>
<CardTitle>Feature List</CardTitle>
<CardDescription>
Features with user stories and acceptance criteria
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4 rounded-full bg-muted p-3">
<Box className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No features yet</h3>
<p className="text-sm text-center text-muted-foreground max-w-sm mb-4">
Start planning your features with user stories and track their progress
</p>
<Button>
<Plus className="mr-2 h-4 w-4" />
Create First Feature
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowRight } from "lucide-react";
import Link from "next/link";
export default async function AnalyzePage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-4xl font-bold mb-2">Analyzing Your Project</h1>
<p className="text-muted-foreground text-lg">
Our AI is reviewing your code and documentation to understand your product
</p>
</div>
{/* Analysis Progress */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Analysis in Progress
</CardTitle>
<CardDescription>This may take a few moments...</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm">Reading repository structure</span>
</div>
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm">Analyzing code patterns</span>
</div>
<div className="flex items-center gap-3">
<Loader2 className="h-3 w-3 animate-spin text-primary" />
<span className="text-sm">Processing ChatGPT conversations</span>
</div>
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
<span className="text-sm text-muted-foreground">Extracting product vision</span>
</div>
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
<span className="text-sm text-muted-foreground">Identifying features</span>
</div>
</div>
</CardContent>
</Card>
{/* Continue Button */}
<div className="flex justify-end pt-4">
<Link href={`/${workspace}/${projectId}/getting-started/summarize`}>
<Button size="lg">
Continue to Summary
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,138 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Github, ArrowRight, Download } from "lucide-react";
import { CursorIcon, OpenAIIcon } from "@/components/icons/custom-icons";
import Link from "next/link";
export default async function ConnectPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-4xl font-bold mb-2">Connect Your Sources</h1>
<p className="text-muted-foreground text-lg">
Install the Cursor extension and connect your development sources. Our AI will analyze all of the information and automatically create your project for you.
</p>
</div>
{/* Connection Cards */}
<div className="space-y-4">
{/* Cursor Extension */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
<CursorIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<CardTitle>Cursor Extension</CardTitle>
<CardDescription>Install our extension to track your development sessions</CardDescription>
</div>
</div>
<Button>
<Download className="h-4 w-4 mr-2" />
Install Extension
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground">
<p>The extension will help us:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Track your coding sessions and AI interactions</li>
<li>Monitor costs and token usage</li>
<li>Generate automatic documentation</li>
<li>Sync your conversations with Vib&apos;n</li>
</ul>
</div>
</CardContent>
</Card>
{/* GitHub Connection */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Github className="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>GitHub Repository</CardTitle>
<CardDescription>Connect your code repository for analysis</CardDescription>
</div>
</div>
<Button>
<Github className="h-4 w-4 mr-2" />
Connect GitHub
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground">
<p>We&apos;ll need access to:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Read your repository code and structure</li>
<li>Access to repository metadata</li>
<li>View commit history</li>
</ul>
</div>
</CardContent>
</Card>
{/* ChatGPT Connection - Optional */}
<Card className="border-dashed">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
<OpenAIIcon className="h-6 w-6 text-green-600" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle>ChatGPT Project (MCP)</CardTitle>
<span className="px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium">
Optional
</span>
</div>
<CardDescription>Connect your ChatGPT conversations and docs</CardDescription>
</div>
</div>
<Button variant="outline">
<OpenAIIcon className="h-4 w-4 mr-2" />
Install MCP
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground">
<p>Install the Model Context Protocol to:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Access your ChatGPT project conversations</li>
<li>Read product documentation and notes</li>
<li>Sync your product vision and requirements</li>
</ul>
</div>
</CardContent>
</Card>
</div>
{/* Continue Button */}
<div className="flex justify-end pt-4">
<Link href={`/${workspace}/${projectId}/getting-started/analyze`}>
<Button size="lg">
Continue to Analyze
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
"use client";
import { useState } from "react";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ProjectSidebar } from "@/components/layout/project-sidebar";
import { useParams } from "next/navigation";
export default function GettingStartedLayout({
children,
}: {
children: React.ReactNode;
}) {
const [activeSection, setActiveSection] = useState("projects");
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
return (
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Project Sidebar - Getting Started Steps */}
<ProjectSidebar projectId={projectId} activeSection={activeSection} workspace={workspace} />
{/* Main Content Area */}
<main className="flex-1 overflow-hidden">
{children}
</main>
{/* Right Panel - AI Assistant */}
<RightPanel />
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle2, ArrowRight, Sparkles } from "lucide-react";
import Link from "next/link";
export default async function SetupPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-4xl font-bold mb-2">Setup Your Project</h1>
<p className="text-muted-foreground text-lg">
We&apos;ve created your project structure based on the analysis
</p>
</div>
{/* Setup Complete */}
<Card className="border-green-500/50 bg-green-500/5">
<CardContent className="pt-6 pb-6">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<div>
<h3 className="text-xl font-semibold mb-1">Project Setup Complete!</h3>
<p className="text-muted-foreground">
Your project has been configured with all the necessary sections
</p>
</div>
</div>
</CardContent>
</Card>
{/* What We Created */}
<Card>
<CardHeader>
<CardTitle>What We&apos;ve Set Up</CardTitle>
<CardDescription>Your project is ready with these sections</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
<div>
<p className="font-medium">Product Vision</p>
<p className="text-sm text-muted-foreground">Your product goals and strategy</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
<div>
<p className="font-medium">Progress Tracking</p>
<p className="text-sm text-muted-foreground">Monitor your development progress</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
<div>
<p className="font-medium">UI UX Design</p>
<p className="text-sm text-muted-foreground">Design and iterate on your screens</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
<div>
<p className="font-medium">Code Repository</p>
<p className="text-sm text-muted-foreground">Connected to your GitHub repo</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
<div>
<p className="font-medium">Deployment & Automation</p>
<p className="text-sm text-muted-foreground">CI/CD and automated workflows</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Start Building Button */}
<div className="flex justify-center pt-4">
<Link href={`/${workspace}/${projectId}/product`}>
<Button size="lg" className="gap-2">
<Sparkles className="h-4 w-4" />
Start Building
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,120 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle2, ArrowRight, Target, Code2, Zap } from "lucide-react";
import Link from "next/link";
export default async function SummarizePage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-4xl font-bold mb-2">Project Summary</h1>
<p className="text-muted-foreground text-lg">
Here&apos;s what we learned about your product
</p>
</div>
{/* Summary Cards */}
<div className="space-y-4">
{/* Product Vision */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Target className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">Product Vision</CardTitle>
<CardDescription>What you&apos;re building</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
An AI-powered development monitoring platform that tracks coding sessions,
analyzes conversations, and maintains living documentation.
</p>
</CardContent>
</Card>
{/* Tech Stack */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
<Code2 className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg">Tech Stack</CardTitle>
<CardDescription>Technologies detected</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">Next.js</span>
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">TypeScript</span>
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">PostgreSQL</span>
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">Node.js</span>
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">Tailwind CSS</span>
</div>
</CardContent>
</Card>
{/* Key Features */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500/10">
<Zap className="h-5 w-5 text-green-600" />
</div>
<div>
<CardTitle className="text-lg">Key Features</CardTitle>
<CardDescription>Main capabilities identified</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
<span>Session tracking and cost monitoring</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
<span>AI-powered code analysis with Gemini 2.0 Flash</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
<span>Automatic documentation generation</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
<span>Cursor IDE extension integration</span>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* Continue Button */}
<div className="flex justify-end pt-4">
<Link href={`/${workspace}/${projectId}/getting-started/setup`}>
<Button size="lg">
Continue to Setup
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
export default function GrowPage() {
const items = [
{ icon: "📣", title: "Marketing copy", desc: "AI-generated landing page, emails, and social posts tailored to your product." },
{ icon: "🎯", title: "Launch channels", desc: "Recommended channels based on your target audience and business model." },
{ icon: "👥", title: "User acquisition", desc: "Onboarding flows, referral mechanics, and early adopter campaigns." },
{ icon: "💬", title: "Community", desc: "Discord, Slack, or forum setup recommendations for your user base." },
];
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 560 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
Grow
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
Marketing, launch strategy, and user acquisition coming once your product is live.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((item, i) => (
<div
key={i}
className="vibn-enter"
style={{
display: "flex", alignItems: "flex-start", gap: 16,
padding: "18px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
boxShadow: "0 1px 2px #1a1a1a05",
animationDelay: `${i * 0.06}s`,
}}
>
<div style={{ fontSize: "1.2rem", flexShrink: 0, marginTop: 2 }}>{item.icon}</div>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>{item.title}</div>
<div style={{ fontSize: "0.8rem", color: "#6b6560", lineHeight: 1.55 }}>{item.desc}</div>
</div>
<span style={{
marginLeft: "auto", flexShrink: 0,
display: "inline-flex", alignItems: "center",
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600,
color: "#9a7b3a", background: "#d4a04a12",
}}>
Soon
</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { Suspense } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
const SECTIONS = [
{
id: "marketing-site",
label: "Marketing Site",
icon: "◌",
title: "Marketing Site",
desc: "Your public-facing website — hero, features, pricing, blog, and landing pages. Connected to your design surface and deployed via your infrastructure.",
items: ["Hero & Landing", "Features", "Pricing Page", "Blog", "Case Studies", "About"],
},
{
id: "communications",
label: "Communications",
icon: "◈",
title: "Communications",
desc: "Outbound messaging — product announcements, newsletters, launch emails, and drip campaigns sent to your audience.",
items: ["Announcements", "Newsletter", "Launch Sequence", "Drip Campaigns"],
},
{
id: "channels",
label: "Channels",
icon: "↗",
title: "Distribution Channels",
desc: "Where your product gets discovered — SEO, social, Product Hunt, app stores, partnerships, and paid acquisition.",
items: ["SEO & Search", "Social Media", "Product Hunt", "App Stores", "Partnerships", "Paid Ads"],
},
{
id: "pages",
label: "Pages",
icon: "▭",
title: "Pages",
desc: "Individual landing pages for campaigns, experiments, and specific audience segments. Build, publish, and A/B test.",
items: ["Campaign Pages", "A/B Tests", "Event Pages", "Partner Pages", "Waitlist"],
},
] as const;
type SectionId = typeof SECTIONS[number]["id"];
const NAV_GROUP: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
};
function GrowthInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const activeId = (searchParams.get("section") ?? "marketing-site") as SectionId;
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
const setSection = (id: string) =>
router.push(`/${workspace}/project/${projectId}/growth?section=${id}`, { scroll: false });
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* Left nav */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
<div style={NAV_GROUP}>Growth</div>
{SECTIONS.map(s => {
const isActive = activeId === s.id;
return (
<button key={s.id} onClick={() => setSection(s.id)} style={{
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: "6px 12px", borderRadius: 5,
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
color: isActive ? "#1a1a1a" : "#5a5550",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
{s.label}
</button>
);
})}
</div>
{/* Content */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
</div>
{/* Feature items */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
{active.items.map(item => (
<div key={item} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
</div>
))}
</div>
{/* CTA */}
<div style={{
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
borderRadius: 12, padding: "24px 28px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 20,
}}>
<div>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>
{active.title} is coming to VIBN
</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
We&apos;re building this section next. Shape it by telling us what you need.
</div>
</div>
<button style={{
background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8,
padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600,
cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0,
}}>
Give feedback
</button>
</div>
</div>
</div>
</div>
);
}
export default function GrowthPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<GrowthInner />
</Suspense>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
// ── Types ─────────────────────────────────────────────────────────────────────
interface InfraApp {
name: string;
domain?: string | null;
coolifyServiceUuid?: string | null;
}
interface ProjectData {
giteaRepo?: string;
giteaRepoUrl?: string;
apps?: InfraApp[];
}
// ── Tab definitions ───────────────────────────────────────────────────────────
const TABS = [
{ id: "builds", label: "Builds", icon: "⬡" },
{ id: "databases", label: "Databases", icon: "◫" },
{ id: "services", label: "Services", icon: "◎" },
{ id: "environment", label: "Environment", icon: "≡" },
{ id: "domains", label: "Domains", icon: "◬" },
{ id: "logs", label: "Logs", icon: "≈" },
] as const;
type TabId = typeof TABS[number]["id"];
// ── Shared empty state ────────────────────────────────────────────────────────
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
return (
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
padding: 60, textAlign: "center", gap: 16,
}}>
<div style={{
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.5rem", color: "#b5b0a6",
}}>
{icon}
</div>
<div>
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
</div>
<div style={{
marginTop: 8, padding: "8px 18px",
background: "#1a1a1a", color: "#fff",
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
opacity: 0.4, cursor: "default",
}}>
Coming soon
</div>
</div>
);
}
// ── Builds tab ────────────────────────────────────────────────────────────────
function BuildsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="⬡"
title="No deployments yet"
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Deployed Apps
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}></span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
{app.domain && (
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
)}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
</div>
</div>
))}
</div>
</div>
);
}
// ── Databases tab ─────────────────────────────────────────────────────────────
function DatabasesTab() {
return (
<ComingSoonPanel
icon="◫"
title="Databases"
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
/>
);
}
// ── Services tab ──────────────────────────────────────────────────────────────
function ServicesTab() {
return (
<ComingSoonPanel
icon="◎"
title="Services"
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
/>
);
}
// ── Environment tab ───────────────────────────────────────────────────────────
function EnvironmentTab({ project }: { project: ProjectData | null }) {
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Environment Variables & Secrets
</div>
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
overflow: "hidden", marginBottom: 20,
}}>
{/* Header row */}
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "10px 18px", background: "#faf8f5",
borderBottom: "1px solid #e8e4dc",
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.06em", textTransform: "uppercase",
}}>
<span>Key</span><span>Value</span><span />
</div>
{/* Placeholder rows */}
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
<div key={k} style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
alignItems: "center",
}}>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}></span>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
</div>
))}
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
<button style={{
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
cursor: "pointer", width: "100%",
}}>
+ Add variable
</button>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
</div>
</div>
);
}
// ── Domains tab ───────────────────────────────────────────────────────────────
function DomainsTab({ project }: { project: ProjectData | null }) {
const apps = (project?.apps ?? []).filter(a => a.domain);
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Domains & SSL
</div>
{apps.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
{app.domain}
</div>
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
</div>
</div>
))}
</div>
) : (
<div style={{
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
padding: "32px 24px", textAlign: "center", marginBottom: 20,
}}>
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
</div>
)}
<button style={{
background: "#1a1a1a", color: "#fff", border: "none",
borderRadius: 8, padding: "9px 20px",
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
opacity: 0.5,
}}>
+ Add domain
</button>
</div>
);
}
// ── Logs tab ──────────────────────────────────────────────────────────────────
function LogsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="≈"
title="No logs yet"
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 900 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Runtime Logs
</div>
<div style={{
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
lineHeight: 1.6, minHeight: 200,
}}>
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
</div>
</div>
);
}
// ── Inner page ────────────────────────────────────────────────────────────────
function InfrastructurePageInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
const [project, setProject] = useState<ProjectData | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
.catch(() => {});
}, [projectId]);
const setTab = (id: TabId) => {
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* ── Left sub-nav ── */}
<div style={{
width: 190, flexShrink: 0,
borderRight: "1px solid #e8e4dc",
background: "#faf8f5",
display: "flex", flexDirection: "column",
padding: "16px 8px",
gap: 2,
overflow: "auto",
}}>
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "0 8px 10px",
}}>
Infrastructure
</div>
{TABS.map(tab => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setTab(tab.id)}
style={{
display: "flex", alignItems: "center", gap: 9,
padding: "7px 10px", borderRadius: 6,
background: active ? "#f0ece4" : "transparent",
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
color: active ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
transition: "background 0.1s",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
{tab.label}
</button>
);
})}
</div>
{/* ── Content ── */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
{activeTab === "builds" && <BuildsTab project={project} />}
{activeTab === "databases" && <DatabasesTab />}
{activeTab === "services" && <ServicesTab />}
{activeTab === "environment" && <EnvironmentTab project={project} />}
{activeTab === "domains" && <DomainsTab project={project} />}
{activeTab === "logs" && <LogsTab project={project} />}
</div>
</div>
);
}
// ── Export ────────────────────────────────────────────────────────────────────
export default function InfrastructurePage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<InfrastructurePageInner />
</Suspense>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
export default function InsightsPage() {
const items = [
{ icon: "📊", title: "Usage analytics", desc: "Page views, active users, retention curves, and funnel analysis." },
{ icon: "⚡", title: "Performance", desc: "Load times, error rates, and infrastructure health at a glance." },
{ icon: "💰", title: "Revenue", desc: "MRR, churn, LTV, and subscription metrics wired from your billing provider." },
{ icon: "🔔", title: "Alerts", desc: "Get notified when key metrics drop or anomalies are detected." },
];
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 560 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
Insights
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
Analytics, performance, and revenue available once your product is deployed.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((item, i) => (
<div
key={i}
className="vibn-enter"
style={{
display: "flex", alignItems: "flex-start", gap: 16,
padding: "18px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
boxShadow: "0 1px 2px #1a1a1a05",
animationDelay: `${i * 0.06}s`,
}}
>
<div style={{ fontSize: "1.2rem", flexShrink: 0, marginTop: 2 }}>{item.icon}</div>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>{item.title}</div>
<div style={{ fontSize: "0.8rem", color: "#6b6560", lineHeight: 1.55 }}>{item.desc}</div>
</div>
<span style={{
marginLeft: "auto", flexShrink: 0,
display: "inline-flex", alignItems: "center",
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600,
color: "#9a7b3a", background: "#d4a04a12",
}}>
Soon
</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,546 +0,0 @@
"use client";
import { use, useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
GitBranch,
ChevronRight,
Search,
Lightbulb,
ShoppingCart,
UserPlus,
Rocket,
Zap,
HelpCircle,
CreditCard,
Loader2,
CheckCircle2,
Circle,
X,
Palette,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
interface WorkItem {
id: string;
title: string;
path: string;
status: "built" | "in_progress" | "missing";
category: string;
sessionsCount: number;
commitsCount: number;
journeyStage?: string;
}
interface AssetNode {
id: string;
name: string;
asset_type: string;
must_have_for_v1: boolean;
asset_metadata: {
why_it_exists: string;
which_user_it_serves?: string;
problem_it_helps_with?: string;
connection_to_magic_moment: string;
journey_stage?: string;
visual_style_notes?: string;
implementation_notes?: string;
};
children?: AssetNode[];
}
interface JourneyStage {
id: string;
name: string;
icon: any;
description: string;
color: string;
items: WorkItem[];
assets: AssetNode[]; // Visual assets for this stage
}
export default function JourneyPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = use(params);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [touchpointAssets, setTouchpointAssets] = useState<AssetNode[]>([]);
const [loading, setLoading] = useState(true);
const [selectedStage, setSelectedStage] = useState<string | null>(null);
const [journeyStages, setJourneyStages] = useState<JourneyStage[]>([]);
useEffect(() => {
loadJourneyData();
}, [projectId]);
const loadJourneyData = async () => {
try {
setLoading(true);
// Load work items for stats
const timelineResponse = await fetch(`/api/projects/${projectId}/timeline-view`);
if (timelineResponse.ok) {
const timelineData = await timelineResponse.json();
setWorkItems(timelineData.workItems);
}
// Load AI-generated touchpoints tree
const mvpResponse = await fetch(`/api/projects/${projectId}/mvp-checklist`);
if (mvpResponse.ok) {
const mvpData = await mvpResponse.json();
// Extract touchpoints from AI response if it exists
if (mvpData.aiGenerated && mvpData.touchpointsTree) {
const allTouchpoints = flattenAssetNodes(mvpData.touchpointsTree.nodes || []);
setTouchpointAssets(allTouchpoints);
}
}
// Build journey stages from both work items and touchpoint assets
const stages = buildJourneyStages(workItems, touchpointAssets);
setJourneyStages(stages);
} catch (error) {
console.error("Error loading journey data:", error);
toast.error("Failed to load journey data");
} finally {
setLoading(false);
}
};
// Flatten nested asset nodes
const flattenAssetNodes = (nodes: AssetNode[]): AssetNode[] => {
const flattened: AssetNode[] = [];
const flatten = (node: AssetNode) => {
flattened.push(node);
if (node.children && node.children.length > 0) {
node.children.forEach(child => flatten(child));
}
};
nodes.forEach(node => flatten(node));
return flattened;
};
const getJourneySection = (item: WorkItem): string => {
const title = item.title.toLowerCase();
const path = item.path.toLowerCase();
// Discovery
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
// Research
if (item.category === 'Content' || path.includes('/docs')) return 'Research';
if (title.includes('marketing dashboard')) return 'Research';
// Onboarding
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
if (path.includes('signup') || path.includes('signin')) return 'Onboarding';
// First Use
if (title.includes('onboarding')) return 'First Use';
if (title.includes('getting started')) return 'First Use';
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
if (title.includes('creation flow')) return 'First Use';
// Active
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
// Support
if (path.includes('settings')) return 'Support';
// Purchase
if (path.includes('billing') || path.includes('payment')) return 'Purchase';
return 'Active';
};
const buildJourneyStages = (items: WorkItem[], assets: AssetNode[]): JourneyStage[] => {
const stageDefinitions = [
{
id: "discovery",
name: "Discovery",
stageMappings: ["Awareness", "Discovery"],
icon: Search,
description: "Found you online via social, blog, or ad",
color: "bg-blue-100 border-blue-300 text-blue-900",
},
{
id: "research",
name: "Research",
stageMappings: ["Curiosity", "Research"],
icon: Lightbulb,
description: "Checking out features, pricing, and value",
color: "bg-purple-100 border-purple-300 text-purple-900",
},
{
id: "onboarding",
name: "Onboarding",
stageMappings: ["First Try", "Onboarding"],
icon: UserPlus,
description: "Creating account to try the product",
color: "bg-green-100 border-green-300 text-green-900",
},
{
id: "first-use",
name: "First Use",
stageMappings: ["First Real Day", "First Use"],
icon: Rocket,
description: "Zero to experiencing the magic",
color: "bg-orange-100 border-orange-300 text-orange-900",
},
{
id: "active",
name: "Active",
stageMappings: ["Habit", "Active", "Post-MVP"],
icon: Zap,
description: "Using the magic repeatedly",
color: "bg-yellow-100 border-yellow-300 text-yellow-900",
},
{
id: "support",
name: "Support",
stageMappings: ["Support"],
icon: HelpCircle,
description: "Getting help to maximize value",
color: "bg-indigo-100 border-indigo-300 text-indigo-900",
},
{
id: "purchase",
name: "Purchase",
stageMappings: ["Decision to Pay", "Purchase"],
icon: CreditCard,
description: "Time to pay to keep using",
color: "bg-pink-100 border-pink-300 text-pink-900",
},
];
return stageDefinitions.map(stage => {
// Get work items for this stage
const stageItems = items.filter(item => {
const section = getJourneySection(item);
return section === stage.name;
});
// Get touchpoint assets for this stage from AI-generated metadata
const stageAssets = assets.filter(asset => {
const assetJourneyStage = asset.asset_metadata?.journey_stage || '';
return stage.stageMappings.some(mapping =>
assetJourneyStage.toLowerCase().includes(mapping.toLowerCase())
);
});
return {
...stage,
items: stageItems,
assets: stageAssets,
};
});
};
const getStatusIcon = (status: string) => {
if (status === "built") return <CheckCircle2 className="h-3 w-3 text-green-600" />;
if (status === "in_progress") return <Circle className="h-3 w-3 text-blue-600 fill-blue-600" />;
return <Circle className="h-3 w-3 text-gray-400" />;
};
const selectedStageData = journeyStages.find(s => s.id === selectedStage);
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Journey Stats</h3>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Stages</span>
<span className="font-medium">{journeyStages.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total Assets</span>
<span className="font-medium">{journeyStages.reduce((sum, stage) => sum + stage.assets.length, 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Work Items</span>
<span className="font-medium">{workItems.length}</span>
</div>
</div>
</div>
</div>
</CollapsibleSidebar>
{/* Main Content */}
<div className="flex-1 flex flex-col bg-background overflow-hidden">
{/* Header */}
<div className="border-b p-4">
<div className="flex items-center gap-4">
<GitBranch className="h-6 w-6" />
<div>
<h1 className="text-xl font-bold">Customer Journey</h1>
<p className="text-sm text-muted-foreground">
Track touchpoints across the customer lifecycle
</p>
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center flex-1">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 overflow-auto">
{/* Journey Flow */}
<div className="p-8">
<div className="flex items-center gap-0 overflow-x-auto pb-4">
{journeyStages.map((stage, index) => (
<div key={stage.id} className="flex items-center flex-shrink-0">
{/* Stage Card */}
<Card
className={`w-64 border-2 cursor-pointer transition-all hover:shadow-lg ${
stage.color
} ${selectedStage === stage.id ? "ring-2 ring-primary" : ""}`}
onClick={() => setSelectedStage(stage.id)}
>
<div className="p-4 space-y-3">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<stage.icon className="h-5 w-5" />
<h3 className="font-bold text-sm">{stage.name}</h3>
</div>
<Badge variant="secondary" className="text-xs">
{stage.items.length}
</Badge>
</div>
{/* Description */}
<p className="text-xs opacity-80 line-clamp-2">{stage.description}</p>
{/* Stats */}
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
<span>
{stage.items.filter(i => i.status === "built").length} built
</span>
</div>
<div className="flex items-center gap-1">
<Circle className="h-3 w-3 fill-current" />
<span>
{stage.items.filter(i => i.status === "in_progress").length} in progress
</span>
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-white/50 rounded-full h-1.5">
<div
className="bg-current h-1.5 rounded-full transition-all"
style={{
width: `${
stage.items.length > 0
? (stage.items.filter(i => i.status === "built").length /
stage.items.length) *
100
: 0
}%`,
}}
/>
</div>
</div>
</Card>
{/* Connector Arrow */}
{index < journeyStages.length - 1 && (
<ChevronRight className="h-8 w-8 text-muted-foreground mx-2 flex-shrink-0" />
)}
</div>
))}
</div>
</div>
{/* Stage Details Panel */}
{selectedStageData && (
<div className="border-t bg-muted/30 p-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<selectedStageData.icon className="h-6 w-6" />
<div>
<h2 className="text-lg font-bold">{selectedStageData.name} Touchpoints</h2>
<p className="text-sm text-muted-foreground">
{selectedStageData.description}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedStage(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{selectedStageData.assets.length === 0 && selectedStageData.items.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">
No assets defined for this stage yet
</p>
<Button className="mt-4" onClick={() => toast.info("AI will generate assets when you regenerate the plan")}>
Generate with AI
</Button>
</Card>
) : (
<div className="space-y-6">
{/* AI-Generated Visual Assets */}
{selectedStageData.assets.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
Visual Assets
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{selectedStageData.assets.map((asset) => (
<Card key={asset.id} className="overflow-hidden hover:shadow-lg transition-all group cursor-pointer">
{/* Visual Preview */}
<div className="aspect-video bg-gradient-to-br from-indigo-50 to-purple-50 relative overflow-hidden border-b">
{/* Placeholder for actual design preview */}
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center">
<Palette className="h-10 w-10 text-indigo-400 mx-auto mb-2" />
<p className="text-xs text-indigo-600 font-medium line-clamp-2">
{asset.name}
</p>
</div>
</div>
{/* V1 Badge */}
{asset.must_have_for_v1 && (
<div className="absolute top-2 right-2">
<Badge variant="default" className="shadow-sm bg-blue-600">
V1
</Badge>
</div>
)}
{/* Asset Type Badge */}
<div className="absolute top-2 left-2">
<Badge variant="secondary" className="shadow-sm text-xs">
{asset.asset_type.replace('_', ' ')}
</Badge>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</div>
{/* Card Content */}
<div className="p-4 space-y-3">
<div>
<h3 className="font-semibold text-sm mb-2">{asset.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{asset.asset_metadata?.why_it_exists}
</p>
</div>
{asset.asset_metadata?.visual_style_notes && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<span className="font-medium">Style:</span>{" "}
{asset.asset_metadata.visual_style_notes}
</p>
</div>
)}
<div className="flex items-center justify-between pt-2">
<Badge variant="outline" className="text-xs">
{asset.asset_metadata?.which_user_it_serves || "All users"}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs gap-1"
onClick={(e) => {
e.stopPropagation();
toast.info("Opening in designer...");
}}
>
<Sparkles className="h-3 w-3" />
Design
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
)}
{/* Existing Work Items */}
{selectedStageData.items.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
Existing Work
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{selectedStageData.items.map((item) => (
<Card key={item.id} className="p-4 hover:bg-accent/50 transition-colors">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(item.status)}
<h3 className="font-semibold text-sm">{item.title}</h3>
</div>
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{item.path}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{item.sessionsCount} sessions</span>
<span></span>
<span>{item.commitsCount} commits</span>
</div>
<Badge variant="outline" className="text-xs">
{item.status === "built"
? "Done"
: item.status === "in_progress"
? "In Progress"
: "To-do"}
</Badge>
</div>
</Card>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* End Main Content */}
</div>
);
}

View File

@@ -1,20 +1,44 @@
import { AppShell } from "@/components/layout/app-shell";
import { ProjectShell } from "@/components/layout/project-shell";
import { query } from "@/lib/db-postgres";
async function getProjectName(projectId: string): Promise<string> {
interface ProjectData {
name: string;
description?: string;
status?: string;
progress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
async function getProjectData(projectId: string): Promise<ProjectData> {
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length > 0) {
const data = rows[0].data;
return data?.productName || data?.name || "Project";
const { data, created_at, updated_at } = rows[0];
return {
name: data?.productName || data?.name || "Project",
description: data?.productVision || data?.description,
status: data?.status,
progress: data?.progress ?? 0,
discoveryPhase: data?.discoveryPhase ?? 0,
capturedData: data?.capturedData ?? {},
createdAt: created_at,
updatedAt: updated_at,
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
creationMode: data?.creationMode ?? "fresh",
};
}
} catch (error) {
console.error("Error fetching project name:", error);
console.error("Error fetching project:", error);
}
return "Project";
return { name: "Project" };
}
export default async function ProjectLayout({
@@ -25,11 +49,24 @@ export default async function ProjectLayout({
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
const projectName = await getProjectName(projectId);
const project = await getProjectData(projectId);
return (
<AppShell workspace={workspace} projectId={projectId} projectName={projectName}>
<ProjectShell
workspace={workspace}
projectId={projectId}
projectName={project.name}
projectDescription={project.description}
projectStatus={project.status}
projectProgress={project.progress}
discoveryPhase={project.discoveryPhase}
capturedData={project.capturedData}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
featureCount={project.featureCount}
creationMode={project.creationMode}
>
{children}
</AppShell>
</ProjectShell>
);
}

View File

@@ -1,321 +0,0 @@
"use client";
import { useParams, usePathname } from "next/navigation";
import {
Megaphone,
MessageSquare,
Globe,
Target,
Rocket,
Sparkles,
Edit,
Plus,
} from "lucide-react";
import {
PageTemplate,
PageSection,
PageCard,
PageGrid,
} from "@/components/layout/page-template";
import { Button } from "@/components/ui/button";
const MARKET_NAV_ITEMS = [
{ title: "Value Proposition", icon: Target, href: "/market" },
{ title: "Messaging Framework", icon: MessageSquare, href: "/market#messaging" },
{ title: "Website Copy", icon: Globe, href: "/market#website" },
{ title: "Launch Strategy", icon: Rocket, href: "/market#launch" },
{ title: "Target Channels", icon: Megaphone, href: "/market#channels" },
];
export default function MarketPage() {
const params = useParams();
const pathname = usePathname();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const sidebarItems = MARKET_NAV_ITEMS.map((item) => {
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
return {
...item,
href: fullHref,
isActive: pathname === fullHref || pathname.startsWith(fullHref),
};
});
return (
<PageTemplate
sidebar={{
items: sidebarItems,
}}
>
{/* Value Proposition */}
<PageSection
title="Value Proposition"
description="Your core message to the market"
headerAction={
<Button size="sm" variant="ghost">
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
}
>
<PageCard>
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Headline
</h3>
<p className="text-2xl font-bold">
Build Your Product Faster with AI-Powered Insights
</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Subheadline
</h3>
<p className="text-lg text-muted-foreground">
Turn conversations into code, design, and marketing - all in one platform
</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Key Benefits
</h3>
<ul className="space-y-2">
<li className="flex items-start gap-2">
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
<span>Save weeks of planning and research</span>
</li>
<li className="flex items-start gap-2">
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
<span>Get AI-generated designs and code structure</span>
</li>
<li className="flex items-start gap-2">
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
<span>Launch with confidence and clarity</span>
</li>
</ul>
</div>
</div>
</PageCard>
</PageSection>
{/* Messaging Framework */}
<PageSection title="Messaging Framework" description="How you talk about your product">
<PageGrid cols={2}>
<PageCard>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
Primary Message
</h3>
<p className="text-sm text-muted-foreground mb-3">
For solo founders and small teams building their first product
</p>
<p className="text-base">
"Stop getting stuck in planning. Start building with AI as your co-founder."
</p>
</PageCard>
<PageCard>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Target className="h-4 w-4 text-muted-foreground" />
Positioning
</h3>
<p className="text-sm text-muted-foreground mb-3">
Different from competitors because...
</p>
<p className="text-base">
"We don't just track - we actively guide you from idea to launch with AI."
</p>
</PageCard>
</PageGrid>
</PageSection>
{/* Website Copy */}
<PageSection
title="Website Copy"
description="Content for your marketing site"
headerAction={
<Button size="sm" variant="ghost">
<Sparkles className="h-4 w-4 mr-2" />
Generate More
</Button>
}
>
<div className="space-y-4">
<PageCard>
<h3 className="font-semibold mb-3">Hero Section</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-xs text-muted-foreground mb-1">Headline</p>
<p className="font-medium">Build Your SaaS from Idea to Launch</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-xs text-muted-foreground mb-1">CTA Button</p>
<p className="font-medium">Start Building Free </p>
</div>
</div>
</PageCard>
<PageCard>
<h3 className="font-semibold mb-3">Features Section</h3>
<PageGrid cols={3}>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-sm font-medium mb-1">🎯 AI Interview</p>
<p className="text-xs text-muted-foreground">
Chat with AI to define your product
</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-sm font-medium mb-1">🎨 Auto Design</p>
<p className="text-xs text-muted-foreground">
Generate UI screens instantly
</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-sm font-medium mb-1">🚀 Launch Plan</p>
<p className="text-xs text-muted-foreground">
Get a complete go-to-market strategy
</p>
</div>
</PageGrid>
</PageCard>
<PageCard>
<h3 className="font-semibold mb-3">Social Proof</h3>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-sm text-muted-foreground italic">
"This tool cut our planning time from 4 weeks to 2 days. Incredible."
</p>
<p className="text-xs text-muted-foreground mt-2">
- Founder Name, Company
</p>
</div>
</PageCard>
</div>
</PageSection>
{/* Launch Strategy */}
<PageSection title="Launch Strategy" description="Your go-to-market plan">
<PageCard>
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Rocket className="h-4 w-4 text-muted-foreground" />
Launch Timeline
</h4>
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="text-xs font-medium text-muted-foreground w-20">
Week 1-2
</div>
<div className="text-sm">Soft launch to beta testers</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="text-xs font-medium text-muted-foreground w-20">
Week 3
</div>
<div className="text-sm">Product Hunt launch</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="text-xs font-medium text-muted-foreground w-20">
Week 4+
</div>
<div className="text-sm">Content marketing & SEO</div>
</div>
</div>
</div>
</div>
</PageCard>
</PageSection>
{/* Target Channels */}
<PageSection
title="Target Channels"
description="Where to reach your audience"
headerAction={
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-2" />
Add Channel
</Button>
}
>
<PageGrid cols={2}>
<PageCard hover>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Globe className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-semibold mb-1">Twitter/X</h3>
<p className="text-sm text-muted-foreground mb-2">
Primary channel for developer audience
</p>
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
High Priority
</span>
</div>
</div>
</PageCard>
<PageCard hover>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Rocket className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-semibold mb-1">Product Hunt</h3>
<p className="text-sm text-muted-foreground mb-2">
Launch day visibility and early adopters
</p>
<span className="text-xs px-2 py-1 rounded-full bg-muted text-muted-foreground font-medium">
Launch Day
</span>
</div>
</div>
</PageCard>
<PageCard hover>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-semibold mb-1">Dev Communities</h3>
<p className="text-sm text-muted-foreground mb-2">
Indie Hackers, Reddit, Discord servers
</p>
<span className="text-xs px-2 py-1 rounded-full bg-muted text-muted-foreground font-medium">
Ongoing
</span>
</div>
</div>
</PageCard>
<PageCard hover>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Globe className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-semibold mb-1">Content Marketing</h3>
<p className="text-sm text-muted-foreground mb-2">
Blog posts, tutorials, case studies
</p>
<span className="text-xs px-2 py-1 rounded-full bg-muted text-muted-foreground font-medium">
Long-term
</span>
</div>
</div>
</PageCard>
</PageGrid>
</PageSection>
</PageTemplate>
);
}

View File

@@ -1,354 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, usePathname } from "next/navigation";
import {
Target,
Users,
AlertCircle,
TrendingUp,
Lightbulb,
Plus,
Edit,
Search,
Loader2,
Layout,
CheckCircle,
DollarSign,
Link as LinkIcon,
} from "lucide-react";
import {
PageTemplate,
PageSection,
PageCard,
PageGrid,
PageEmptyState,
} from "@/components/layout/page-template";
import { Button } from "@/components/ui/button";
import { MissionContextTree } from "@/components/mission/mission-context-tree";
import { MissionIdeaSection } from "@/components/mission/mission-idea-section";
import { auth } from "@/lib/firebase/config";
import { toast } from "sonner";
const MISSION_NAV_ITEMS = [
{ title: "Target Customer", icon: Users, href: "/mission" },
{ title: "Existing Solutions", icon: Layout, href: "/mission#solutions" },
];
interface MissionFramework {
targetCustomer: {
primaryAudience: string;
theirSituation: string;
relatedMarkets?: string[];
};
existingSolutions: Array<{
category: string;
description: string;
products: Array<{
name: string;
url?: string;
}>;
}>;
innovations: Array<{
title: string;
description: string;
}>;
ideaValidation: Array<{
title: string;
description: string;
}>;
financialSuccess: {
subscribers: number;
pricePoint: number;
retentionRate: number;
};
}
export default function MissionPage() {
const params = useParams();
const pathname = usePathname();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const [researchingMarket, setResearchingMarket] = useState(false);
const [framework, setFramework] = useState<MissionFramework | null>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
// Fetch mission framework on mount
useEffect(() => {
fetchFramework();
}, [projectId]);
const fetchFramework = async () => {
setLoading(true);
try {
// Fetch project data from Firestore to get the saved framework
const user = auth.currentUser;
const headers: HeadersInit = {};
if (user) {
const token = await user.getIdToken();
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/projects/${projectId}`, {
headers,
});
if (response.ok) {
const data = await response.json();
if (data.project?.phaseData?.missionFramework) {
setFramework(data.project.phaseData.missionFramework);
console.log('[Mission] Loaded saved framework');
} else {
console.log('[Mission] No saved framework found');
}
}
} catch (error) {
console.error('[Mission] Error fetching framework:', error);
} finally {
setLoading(false);
}
};
const handleGenerateFramework = async () => {
setGenerating(true);
try {
const user = auth.currentUser;
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (user) {
const token = await user.getIdToken();
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/projects/${projectId}/mission/generate`, {
method: 'POST',
headers,
});
if (!response.ok) {
throw new Error('Failed to generate mission framework');
}
const data = await response.json();
setFramework(data.framework);
toast.success('Mission framework generated successfully!');
} catch (error) {
console.error('Error generating framework:', error);
toast.error('Failed to generate mission framework');
} finally {
setGenerating(false);
}
};
const handleResearchMarket = async () => {
setResearchingMarket(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
return;
}
const token = await user.getIdToken();
const response = await fetch(`/api/projects/${projectId}/research/market`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to conduct market research');
}
const data = await response.json();
toast.success(
`Market research complete! Found ${data.research.targetNiches.length} niches, ` +
`${data.research.competitors.length} competitors, and ${data.research.marketGaps.length} gaps.`
);
// Regenerate framework with new insights
await handleGenerateFramework();
} catch (error) {
console.error('Error conducting market research:', error);
toast.error('Failed to conduct market research');
} finally {
setResearchingMarket(false);
}
};
// Build sidebar items with full hrefs and active states
const sidebarItems = MISSION_NAV_ITEMS.map((item) => {
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
return {
...item,
href: fullHref,
isActive: pathname === fullHref || pathname.startsWith(fullHref),
};
});
if (loading) {
return (
<PageTemplate
sidebar={{
items: sidebarItems,
customContent: <MissionContextTree projectId={projectId} />,
}}
>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</PageTemplate>
);
}
if (!framework) {
return (
<PageTemplate
sidebar={{
items: sidebarItems,
customContent: <MissionContextTree projectId={projectId} />,
}}
>
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-6 mb-4">
<Lightbulb className="h-12 w-12 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold mb-2">No Mission Framework Yet</h3>
<p className="text-muted-foreground mb-6 max-w-md">
Generate your mission framework based on your project's insights and knowledge
</p>
<Button onClick={handleGenerateFramework} disabled={generating} size="lg">
{generating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Generate Mission Framework
</>
)}
</Button>
</div>
</PageTemplate>
);
}
return (
<PageTemplate
sidebar={{
items: sidebarItems,
customContent: <MissionContextTree projectId={projectId} />,
}}
>
{/* Target Customer */}
<PageSection
title="Target Customer"
description="Who you're building for"
headerAction={
<Button size="sm" variant="ghost" onClick={handleGenerateFramework} disabled={generating}>
{generating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Edit className="h-4 w-4 mr-2" />
)}
Regenerate
</Button>
}
>
<PageCard>
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-2">Primary Audience</h4>
<p className="text-muted-foreground">
{framework.targetCustomer.primaryAudience}
</p>
</div>
<div>
<h4 className="font-semibold mb-2">Their Situation</h4>
<p className="text-muted-foreground">
{framework.targetCustomer.theirSituation}
</p>
</div>
{framework.targetCustomer.relatedMarkets && framework.targetCustomer.relatedMarkets.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Related Markets</h4>
<ul className="list-disc list-inside space-y-1">
{framework.targetCustomer.relatedMarkets.map((market, idx) => (
<li key={idx} className="text-sm text-muted-foreground">
{market}
</li>
))}
</ul>
</div>
)}
</div>
</PageCard>
</PageSection>
{/* Existing Solutions */}
<PageSection
title="Existing Solutions"
description="What alternatives already exist"
headerAction={
<Button
size="sm"
variant="default"
onClick={handleResearchMarket}
disabled={researchingMarket}
>
{researchingMarket ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Researching...
</>
) : (
<>
<Search className="h-4 w-4 mr-2" />
Research Market
</>
)}
</Button>
}
>
<div className="grid grid-cols-2 gap-4">
{framework.existingSolutions.map((solution, idx) => (
<PageCard key={idx}>
<h4 className="font-semibold text-sm mb-3">{solution.category}</h4>
{solution.products && solution.products.length > 0 && (
<div className="space-y-2">
{solution.products.map((product, prodIdx) => (
<div key={prodIdx} className="text-sm">
{product.url ? (
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{product.name}
<LinkIcon className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground">{product.name}</span>
)}
</div>
))}
</div>
)}
</PageCard>
))}
</div>
</PageSection>
</PageTemplate>
);
}

View File

@@ -1,302 +0,0 @@
"use client";
import { useParams, usePathname } from "next/navigation";
import {
DollarSign,
Receipt,
CreditCard,
TrendingUp,
Plus,
Calendar,
} from "lucide-react";
import {
PageTemplate,
PageSection,
PageCard,
PageGrid,
} from "@/components/layout/page-template";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const MONEY_NAV_ITEMS = [
{ title: "Expenses", icon: Receipt, href: "/money" },
{ title: "Costs", icon: TrendingUp, href: "/money#costs" },
{ title: "Pricing", icon: DollarSign, href: "/money#pricing" },
{ title: "Plans", icon: CreditCard, href: "/money#plans" },
];
const SAMPLE_EXPENSES = [
{ id: 1, name: "Logo Design", amount: 299, date: "2025-01-15", category: "Design" },
{ id: 2, name: "Domain Registration", amount: 12, date: "2025-01-10", category: "Infrastructure" },
{ id: 3, name: "SSL Certificate", amount: 69, date: "2025-01-08", category: "Infrastructure" },
];
const SAMPLE_COSTS = [
{ id: 1, name: "Vercel Hosting", amount: 20, frequency: "monthly", category: "Infrastructure" },
{ id: 2, name: "OpenAI API", amount: 45, frequency: "monthly", category: "Services" },
{ id: 3, name: "SendGrid Email", amount: 15, frequency: "monthly", category: "Services" },
{ id: 4, name: "Stripe Fees", amount: 0, frequency: "per transaction", category: "Services" },
];
export default function MoneyPage() {
const params = useParams();
const pathname = usePathname();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const sidebarItems = MONEY_NAV_ITEMS.map((item) => {
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
return {
...item,
href: fullHref,
isActive: pathname === fullHref || pathname.startsWith(fullHref),
};
});
const totalExpenses = SAMPLE_EXPENSES.reduce((sum, e) => sum + e.amount, 0);
const monthlyCosts = SAMPLE_COSTS.filter(c => c.frequency === "monthly").reduce((sum, c) => sum + c.amount, 0);
const annualCosts = monthlyCosts * 12;
return (
<PageTemplate
sidebar={{
items: sidebarItems,
}}
>
{/* Financial Overview */}
<PageSection>
<PageGrid cols={4}>
<PageCard>
<div className="text-center">
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-3xl font-bold">${totalExpenses}</p>
<p className="text-sm text-muted-foreground">Total Expenses</p>
<p className="text-xs text-muted-foreground mt-1">One-time</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-3xl font-bold">${monthlyCosts}</p>
<p className="text-sm text-muted-foreground">Monthly Costs</p>
<p className="text-xs text-muted-foreground mt-1">Recurring</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
<Calendar className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-3xl font-bold">${annualCosts}</p>
<p className="text-sm text-muted-foreground">Annual Costs</p>
<p className="text-xs text-muted-foreground mt-1">Projected</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
<DollarSign className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-3xl font-bold">$0</p>
<p className="text-sm text-muted-foreground">Revenue</p>
<p className="text-xs text-muted-foreground mt-1">Not launched</p>
</div>
</PageCard>
</PageGrid>
</PageSection>
{/* Expenses (One-time) */}
<PageSection
title="Expenses"
description="One-time costs"
headerAction={
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-2" />
Add Expense
</Button>
}
>
<PageCard>
<div className="space-y-2">
{SAMPLE_EXPENSES.map((expense) => (
<div
key={expense.id}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"
>
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Receipt className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="font-medium">{expense.name}</p>
<p className="text-xs text-muted-foreground">{expense.date}</p>
</div>
<div>
<span className="text-xs px-2 py-1 rounded-full bg-muted font-medium">
{expense.category}
</span>
</div>
<div className="text-right">
<p className="font-semibold">${expense.amount}</p>
</div>
</div>
))}
</div>
</PageCard>
</PageSection>
{/* Costs (Recurring) */}
<PageSection
title="Costs"
description="Recurring/ongoing expenses"
headerAction={
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-2" />
Add Cost
</Button>
}
>
<PageCard>
<div className="space-y-2">
{SAMPLE_COSTS.map((cost) => (
<div
key={cost.id}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"
>
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="font-medium">{cost.name}</p>
<p className="text-xs text-muted-foreground capitalize">{cost.frequency}</p>
</div>
<div>
<span className="text-xs px-2 py-1 rounded-full bg-muted font-medium">
{cost.category}
</span>
</div>
<div className="text-right">
<p className="font-semibold">
{cost.amount === 0 ? "Variable" : `$${cost.amount}`}
</p>
</div>
</div>
))}
</div>
</PageCard>
</PageSection>
{/* Pricing Strategy */}
<PageSection
title="Pricing"
description="Your product pricing strategy"
>
<PageCard>
<div className="text-center py-8 text-muted-foreground">
<DollarSign className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm mb-4">
Define your pricing tiers and revenue model
</p>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Create Pricing Plan
</Button>
</div>
</PageCard>
</PageSection>
{/* Plans (Revenue Tiers) */}
<PageSection
title="Plans"
description="Subscription tiers and offerings"
>
<PageGrid cols={3}>
<PageCard>
<div className="text-center">
<h3 className="font-semibold text-lg mb-2">Free</h3>
<p className="text-3xl font-bold mb-1">$0</p>
<p className="text-xs text-muted-foreground mb-4">per month</p>
<ul className="text-sm space-y-2 text-left mb-6">
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>Basic features</span>
</li>
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>Community support</span>
</li>
</ul>
</div>
</PageCard>
<PageCard className="border-primary">
<div className="text-center">
<div className="inline-block px-2 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-2">
Popular
</div>
<h3 className="font-semibold text-lg mb-2">Pro</h3>
<p className="text-3xl font-bold mb-1">$29</p>
<p className="text-xs text-muted-foreground mb-4">per month</p>
<ul className="text-sm space-y-2 text-left mb-6">
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>All features</span>
</li>
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>Priority support</span>
</li>
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>API access</span>
</li>
</ul>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<h3 className="font-semibold text-lg mb-2">Enterprise</h3>
<p className="text-3xl font-bold mb-1">Custom</p>
<p className="text-xs text-muted-foreground mb-4">contact us</p>
<ul className="text-sm space-y-2 text-left mb-6">
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>Unlimited everything</span>
</li>
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>Dedicated support</span>
</li>
<li className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
</div>
<span>Custom integrations</span>
</li>
</ul>
</div>
</PageCard>
</PageGrid>
</PageSection>
</PageTemplate>
);
}

View File

@@ -3,504 +3,109 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
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;
}
import { Loader2 } from "lucide-react";
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
import { MigrateMain } from "@/components/project-main/MigrateMain";
interface Project {
id: string;
name: string;
productName: string;
productVision?: string;
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;
}
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`;
}
function DeployBadge({ status }: { status?: string }) {
if (!status) return <Badge variant="secondary">No deployments</Badge>;
const map: Record<string, { label: string; icon: React.ElementType; className: string }> = {
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" },
name?: string;
stage?: "discovery" | "architecture" | "building" | "active";
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
creationStage?: string;
sourceData?: {
chatText?: string;
repoUrl?: string;
liveUrl?: string;
hosting?: string;
description?: string;
};
const cfg = map[status] ?? { label: status, icon: AlertCircle, className: "bg-gray-500/10 text-gray-500" };
const Icon = cfg.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${cfg.className}`}>
<Icon className="h-3 w-3" />
{cfg.label}
</span>
);
analysisResult?: Record<string, unknown>;
migrationPlan?: string;
}
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<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [provisioning, setProvisioning] = 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(() => {
if (authStatus === "authenticated") fetchProject();
else if (authStatus === "unauthenticated") setLoading(false);
}, [authStatus, projectId]);
const handleRefresh = () => {
setRefreshing(true);
fetchProject();
};
const handleProvisionWorkspace = async () => {
setProvisioning(true);
try {
const res = await fetch(`/api/projects/${projectId}/workspace`, { method: 'POST' });
const data = await res.json();
if (res.ok && data.workspaceUrl) {
toast.success('Workspace provisioned — starting up…');
await fetchProject();
} else {
toast.error(data.error || 'Failed to provision workspace');
}
} catch {
toast.error('An error occurred');
} finally {
setProvisioning(false);
if (authStatus !== "authenticated") {
if (authStatus === "unauthenticated") setLoading(false);
return;
}
};
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setProject(d.project))
.catch(() => {})
.finally(() => setLoading(false));
}, [authStatus, projectId]);
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
</div>
);
}
if (error || !project) {
if (!project) {
return (
<div className="container mx-auto py-8 px-6 max-w-5xl">
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="py-8 text-center">
<p className="text-sm text-red-600">{error ?? "Project not found"}</p>
</CardContent>
</Card>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
Project not found.
</div>
);
}
const snap = project.contextSnapshot;
const gitea_url = process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.vibnai.com";
const projectName = project.productName || project.name || "Untitled";
const mode = project.creationMode ?? "fresh";
if (mode === "chat-import") {
return (
<ChatImportMain
projectId={projectId}
projectName={projectName}
sourceData={project.sourceData}
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
/>
);
}
if (mode === "code-import") {
return (
<CodeImportMain
projectId={projectId}
projectName={projectName}
sourceData={project.sourceData}
analysisResult={project.analysisResult}
creationStage={project.creationStage}
/>
);
}
if (mode === "migration") {
return (
<MigrateMain
projectId={projectId}
projectName={projectName}
sourceData={project.sourceData}
analysisResult={project.analysisResult}
migrationPlan={project.migrationPlan}
creationStage={project.creationStage}
/>
);
}
// Default: "fresh" — wraps AtlasChat with decision banner
return (
<div className="container mx-auto py-8 px-6 max-w-5xl space-y-6">
{/* ── Header ── */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">{project.productName}</h1>
{project.productVision && (
<p className="text-muted-foreground text-sm mt-1 max-w-xl">{project.productVision}</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant={project.status === "active" ? "default" : "secondary"}>
{project.status ?? "active"}
</Badge>
{project.currentPhase && (
<Badge variant="outline">{project.currentPhase}</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
{project.theiaWorkspaceUrl ? (
<Button size="sm" asChild>
<a href={project.theiaWorkspaceUrl} target="_blank" rel="noopener noreferrer">
<Terminal className="h-4 w-4 mr-1.5" />
Open IDE
</a>
</Button>
) : (
<Button size="sm" onClick={handleProvisionWorkspace} disabled={provisioning}>
{provisioning
? <><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Provisioning</>
: <><Terminal className="h-4 w-4 mr-1.5" />Provision IDE</>
}
</Button>
)}
</div>
</div>
{/* ── Quick Stats ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ 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 }) => (
<Card key={label}>
<CardContent className="pt-5 pb-4">
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* ── Code / Gitea ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Code2 className="h-4 w-4" />
Code Repository
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{project.giteaRepo ? (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-mono text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
{snap?.currentBranch ?? "main"}
</div>
<a
href={project.giteaRepoUrl ?? `${gitea_url}/${project.giteaRepo}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary flex items-center gap-1 hover:underline"
>
{project.giteaRepo}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{snap?.lastCommit ? (
<div className="rounded-md border bg-muted/30 p-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<GitCommit className="h-3.5 w-3.5" />
<span className="font-mono">{snap.lastCommit.sha.slice(0, 8)}</span>
<span>·</span>
<span>{timeAgo(snap.lastCommit.timestamp)}</span>
{snap.lastCommit.author && <span>· {snap.lastCommit.author}</span>}
</div>
<p className="text-sm font-medium line-clamp-1">{snap.lastCommit.message}</p>
</div>
) : (
<p className="text-xs text-muted-foreground">No commits yet push to get started</p>
)}
<div className="text-xs text-muted-foreground space-y-1 pt-1 border-t">
<p className="font-medium text-foreground">Clone</p>
<p className="font-mono break-all">{project.giteaCloneUrl}</p>
{project.giteaSshUrl && (
<p className="font-mono break-all">{project.giteaSshUrl}</p>
)}
</div>
</>
) : (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">
{project.giteaError
? `Repo provisioning failed: ${project.giteaError}`
: "No repository linked"}
</p>
</div>
)}
</CardContent>
</Card>
{/* ── Deployment ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Rocket className="h-4 w-4" />
Deployment
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{snap?.lastDeployment ? (
<>
<div className="flex items-center justify-between">
<DeployBadge status={snap.lastDeployment.status} />
<span className="text-xs text-muted-foreground">{timeAgo(snap.lastDeployment.timestamp)}</span>
</div>
{snap.lastDeployment.url && (
<a
href={snap.lastDeployment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
{snap.lastDeployment.url}
</a>
)}
</>
) : (
<div className="text-center py-4 space-y-3">
<p className="text-sm text-muted-foreground">No deployments yet</p>
<Button size="sm" variant="outline" asChild>
<Link href={`/${workspace}/project/${projectId}/deployment`}>
Set up deployment
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Open PRs ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
Pull Requests
{(snap?.openPRs?.length ?? 0) > 0 && (
<Badge variant="secondary" className="ml-auto">{snap!.openPRs!.length} open</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{snap?.openPRs?.length ? (
<ul className="space-y-2">
{snap.openPRs.map(pr => (
<li key={pr.number}>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
>
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{pr.number}</span>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{pr.title}</p>
<p className="text-xs text-muted-foreground">{pr.from} {pr.into}</p>
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No open pull requests</p>
)}
</CardContent>
</Card>
{/* ── Open Issues ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CircleDot className="h-4 w-4" />
Issues
{(snap?.openIssues?.length ?? 0) > 0 && (
<Badge variant="secondary" className="ml-auto">{snap!.openIssues!.length} open</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{snap?.openIssues?.length ? (
<ul className="space-y-2">
{snap.openIssues.map(issue => (
<li key={issue.number}>
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
>
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{issue.number}</span>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{issue.title}</p>
{issue.labels?.length ? (
<div className="flex gap-1 flex-wrap mt-0.5">
{issue.labels.map(l => (
<span key={l} className="text-[10px] px-1.5 py-0.5 bg-muted rounded-full">{l}</span>
))}
</div>
) : null}
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No open issues</p>
)}
</CardContent>
</Card>
</div>
{/* ── Recent Commits ── */}
{snap?.recentCommits && snap.recentCommits.length > 1 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<GitCommit className="h-4 w-4" />
Recent Commits
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{snap.recentCommits.map((c, i) => (
<li key={i} className="flex items-center gap-3 text-sm py-1.5 border-b last:border-0">
<span className="font-mono text-xs text-muted-foreground w-16 shrink-0">{c.sha.slice(0, 8)}</span>
<span className="flex-1 line-clamp-1">{c.message}</span>
<span className="text-xs text-muted-foreground shrink-0">{c.author ?? ""}</span>
<span className="text-xs text-muted-foreground shrink-0 w-16 text-right">{timeAgo(c.timestamp)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* ── Resources ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Database className="h-4 w-4" />
Resources
</CardTitle>
<CardDescription className="text-xs">Databases and services linked to this project</CardDescription>
</CardHeader>
<CardContent>
{project.coolifyDbUuid ? (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Database provisioned</span>
<Badge variant="outline" className="text-xs ml-auto">{project.coolifyDbUuid}</Badge>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">No databases provisioned yet</p>
<Button size="sm" variant="outline">
<Database className="h-3.5 w-3.5 mr-1.5" />
Add Database
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Context snapshot freshness ── */}
{snap?.updatedAt && (
<p className="text-xs text-muted-foreground text-right">
Context updated {timeAgo(snap.updatedAt)} via webhooks
</p>
)}
</div>
<FreshIdeaMain
projectId={projectId}
projectName={projectName}
/>
);
}

View File

@@ -1,298 +0,0 @@
"use client";
import { useParams, usePathname } from "next/navigation";
import {
ClipboardList,
CheckCircle2,
Circle,
Clock,
Target,
ListTodo,
Calendar,
Plus,
Sparkles,
} from "lucide-react";
import {
PageTemplate,
PageSection,
PageCard,
PageGrid,
} from "@/components/layout/page-template";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const BUILD_PLAN_NAV_ITEMS = [
{ title: "MVP Scope", icon: Target, href: "/build-plan" },
{ title: "Backlog", icon: ListTodo, href: "/build-plan#backlog" },
{ title: "Milestones", icon: Calendar, href: "/build-plan#milestones" },
{ title: "Progress", icon: Clock, href: "/build-plan#progress" },
];
const SAMPLE_MVP_FEATURES = [
{ id: 1, title: "User Authentication", status: "completed", priority: "high" },
{ id: 2, title: "Dashboard UI", status: "in_progress", priority: "high" },
{ id: 3, title: "Core Feature Flow", status: "in_progress", priority: "high" },
{ id: 4, title: "Payment Integration", status: "todo", priority: "medium" },
{ id: 5, title: "Email Notifications", status: "todo", priority: "low" },
];
const SAMPLE_BACKLOG = [
{ id: 1, title: "Advanced Analytics", priority: "medium" },
{ id: 2, title: "Team Collaboration", priority: "high" },
{ id: 3, title: "API Access", priority: "low" },
{ id: 4, title: "Mobile App", priority: "medium" },
];
export default function BuildPlanPage() {
const params = useParams();
const pathname = usePathname();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const sidebarItems = BUILD_PLAN_NAV_ITEMS.map((item) => {
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
return {
...item,
href: fullHref,
isActive: pathname === fullHref || pathname.startsWith(fullHref),
};
});
const completedCount = SAMPLE_MVP_FEATURES.filter((f) => f.status === "completed").length;
const totalCount = SAMPLE_MVP_FEATURES.length;
const progressPercent = Math.round((completedCount / totalCount) * 100);
return (
<PageTemplate
sidebar={{
title: "Build Plan",
description: "Track what needs to be built",
items: sidebarItems,
footer: (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{completedCount} of {totalCount} MVP features done
</p>
<div className="h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
),
}}
hero={{
icon: ClipboardList,
title: "Build Plan",
description: "Manage your MVP scope and track progress",
actions: [
{
label: "Generate Tasks",
onClick: () => console.log("Generate tasks with AI"),
icon: Sparkles,
},
],
}}
>
{/* Progress Overview */}
<PageSection>
<PageGrid cols={4}>
<PageCard>
<div className="text-center">
<CheckCircle2 className="h-8 w-8 text-green-600 mx-auto mb-2" />
<p className="text-3xl font-bold">{completedCount}</p>
<p className="text-sm text-muted-foreground">Completed</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<Clock className="h-8 w-8 text-blue-600 mx-auto mb-2" />
<p className="text-3xl font-bold">
{SAMPLE_MVP_FEATURES.filter((f) => f.status === "in_progress").length}
</p>
<p className="text-sm text-muted-foreground">In Progress</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<Circle className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-3xl font-bold">
{SAMPLE_MVP_FEATURES.filter((f) => f.status === "todo").length}
</p>
<p className="text-sm text-muted-foreground">To Do</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<Target className="h-8 w-8 text-primary mx-auto mb-2" />
<p className="text-3xl font-bold">{progressPercent}%</p>
<p className="text-sm text-muted-foreground">Progress</p>
</div>
</PageCard>
</PageGrid>
</PageSection>
{/* MVP Scope */}
<PageSection
title="MVP Scope"
description="Features included in your minimum viable product"
headerAction={
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-2" />
Add Feature
</Button>
}
>
<PageCard>
<div className="space-y-2">
{SAMPLE_MVP_FEATURES.map((feature) => (
<div
key={feature.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg border transition-all hover:border-primary/50",
feature.status === "completed" && "bg-green-50/50 dark:bg-green-950/20"
)}
>
<div className="shrink-0">
{feature.status === "completed" && (
<CheckCircle2 className="h-5 w-5 text-green-600" />
)}
{feature.status === "in_progress" && (
<Clock className="h-5 w-5 text-blue-600" />
)}
{feature.status === "todo" && (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1">
<p
className={cn(
"font-medium",
feature.status === "completed" && "line-through text-muted-foreground"
)}
>
{feature.title}
</p>
</div>
<div>
<span
className={cn(
"text-xs px-2 py-1 rounded-full",
feature.priority === "high" &&
"bg-red-500/10 text-red-700 dark:text-red-400",
feature.priority === "medium" &&
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
feature.priority === "low" &&
"bg-gray-500/10 text-gray-700 dark:text-gray-400"
)}
>
{feature.priority}
</span>
</div>
<div>
<span
className={cn(
"text-xs px-2 py-1 rounded-full",
feature.status === "completed" &&
"bg-green-500/10 text-green-700 dark:text-green-400",
feature.status === "in_progress" &&
"bg-blue-500/10 text-blue-700 dark:text-blue-400",
feature.status === "todo" &&
"bg-gray-500/10 text-gray-700 dark:text-gray-400"
)}
>
{feature.status === "in_progress" ? "in progress" : feature.status}
</span>
</div>
</div>
))}
</div>
</PageCard>
</PageSection>
{/* Backlog */}
<PageSection
title="Backlog"
description="Features for future iterations"
headerAction={
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-2" />
Add to Backlog
</Button>
}
>
<PageCard>
<div className="space-y-2">
{SAMPLE_BACKLOG.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 p-3 rounded-lg border hover:border-primary/50 transition-all"
>
<ListTodo className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1">
<p className="font-medium">{item.title}</p>
</div>
<span
className={cn(
"text-xs px-2 py-1 rounded-full",
item.priority === "high" &&
"bg-red-500/10 text-red-700 dark:text-red-400",
item.priority === "medium" &&
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
item.priority === "low" &&
"bg-gray-500/10 text-gray-700 dark:text-gray-400"
)}
>
{item.priority}
</span>
<Button size="sm" variant="ghost">
Move to MVP
</Button>
</div>
))}
</div>
</PageCard>
</PageSection>
{/* Milestones */}
<PageSection title="Milestones" description="Key dates and goals">
<PageGrid cols={3}>
<PageCard>
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-3">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<h3 className="font-semibold mb-1">Alpha Release</h3>
<p className="text-sm text-muted-foreground mb-2">Completed</p>
<p className="text-xs text-muted-foreground">Jan 15, 2025</p>
</div>
</PageCard>
<PageCard className="border-primary">
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-blue-500/10 flex items-center justify-center mx-auto mb-3">
<Clock className="h-6 w-6 text-blue-600" />
</div>
<h3 className="font-semibold mb-1">Beta Launch</h3>
<p className="text-sm text-muted-foreground mb-2">In Progress</p>
<p className="text-xs text-muted-foreground">Feb 1, 2025</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
<Target className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold mb-1">Public Launch</h3>
<p className="text-sm text-muted-foreground mb-2">Planned</p>
<p className="text-xs text-muted-foreground">Mar 1, 2025</p>
</div>
</PageCard>
</PageGrid>
</PageSection>
</PageTemplate>
);
}

View File

@@ -1,768 +0,0 @@
'use client';
import { use, useState, useEffect, useCallback } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, CheckCircle2, Circle, Clock, RefreshCw, Eye, Cog, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
import { CollapsibleSidebar } from '@/components/ui/collapsible-sidebar';
interface WorkItem {
id: string;
title: string;
category: string;
path: string;
status: 'built' | 'missing' | 'in_progress';
priority: string;
assigned?: string;
startDate: string | null;
endDate: string | null;
duration: number;
sessionsCount: number;
commitsCount: number;
totalActivity: number;
estimatedCost?: number;
requirements: Array<{
id: number;
text: string;
status: 'built' | 'missing' | 'in_progress';
}>;
evidence: string[];
note?: string;
}
interface TimelineData {
workItems: WorkItem[];
timeline: {
start: string;
end: string;
totalDays: number;
};
summary: {
totalWorkItems: number;
withActivity: number;
noActivity: number;
built: number;
missing: number;
};
projectCreator?: string;
}
export default function TimelinePlanPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { projectId } = use(params);
const [data, setData] = useState<TimelineData | null>(null);
const [loading, setLoading] = useState(true);
const [regenerating, setRegenerating] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<'touchpoints' | 'technical' | 'journey'>('touchpoints');
const [collapsedJourneySections, setCollapsedJourneySections] = useState<Set<string>>(new Set());
// Map work items to types based on path and category
const getWorkItemType = (item: WorkItem): string => {
// API endpoints are System
if (item.path.startsWith('/api/')) return 'System';
// Flows are Flow
if (item.path.startsWith('flow/')) return 'Flow';
// Auth/OAuth is System
if (item.path.includes('auth') || item.path.includes('oauth')) return 'System';
// Settings is System
if (item.path.includes('settings')) return 'System';
// Marketing/Content pages
if (item.category === 'Marketing' || item.category === 'Content') return 'Screen';
// Social
if (item.category === 'Social') return 'Screen';
// Everything else is a Screen
return 'Screen';
};
// Determine if item is a user-facing touchpoint
const isTouchpoint = (item: WorkItem): boolean => {
const path = item.path.toLowerCase();
const title = item.title.toLowerCase();
// Exclude APIs and backend systems
if (path.startsWith('/api/')) return false;
if (title.includes(' api') || title.includes('api ')) return false;
// Exclude pure auth infrastructure (OAuth endpoints)
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false;
// Include everything else - screens, pages, social posts, blogs, invites, etc.
return true;
};
// Determine if item is technical infrastructure
const isTechnical = (item: WorkItem): boolean => {
const path = item.path.toLowerCase();
const title = item.title.toLowerCase();
// APIs and backend
if (path.startsWith('/api/')) return true;
if (title.includes(' api') || title.includes('api ')) return true;
// Auth infrastructure
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
// System settings
if (item.category === 'Settings' && title.includes('api')) return true;
return false;
};
// Map work items to customer lifecycle journey sections
const getJourneySection = (item: WorkItem): string => {
const title = item.title.toLowerCase();
const path = item.path.toLowerCase();
// Discovery - "I just found you online via social post, blog article, advertisement"
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
if (item.category === 'Content' && (title.includes('blog') || title.includes('article'))) return 'Discovery';
// Research - "Checking out your marketing website - features, price, home page"
if (title.includes('marketing dashboard')) return 'Research';
if (item.category === 'Marketing' && path !== '/') return 'Research';
if (path.includes('/features') || path.includes('/pricing') || path.includes('/about')) return 'Research';
if (item.category === 'Content' && path.includes('/docs') && !title.includes('getting started')) return 'Research';
// Onboarding - "Creating an account to try the product for the first time"
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
if (path.includes('signup') || path.includes('signin') || path.includes('login')) return 'Onboarding';
if (title.includes('authentication') && !title.includes('api')) return 'Onboarding';
// First Use - "Zero state to experiencing the magic solution"
if (title.includes('onboarding')) return 'First Use';
if (title.includes('getting started')) return 'First Use';
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
if (title.includes('creation flow') || title.includes('project creation')) return 'First Use';
if (path.includes('/projects') && path.match(/\/projects\/?$/)) return 'First Use'; // Projects list page
// Active - "I've seen the magic and come back to use it again and again"
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
if (title.includes('plan generation') || title.includes('marketing plan')) return 'Active';
if (path.includes('projects/') && path.length > '/projects/'.length) return 'Active'; // Specific project pages
// Support - "I've got questions, need quick answers to get back to the magic"
if (path.includes('settings')) return 'Support';
if (path.includes('/help') || path.includes('/faq') || path.includes('/support')) return 'Support';
if (item.category === 'Content' && path.includes('/docs') && title.includes('help')) return 'Support';
// Purchase - "Time to pay so I can keep using the magic"
if (path.includes('billing') || path.includes('payment') || path.includes('subscription')) return 'Purchase';
if (path.includes('upgrade') || path.includes('checkout') || path.includes('pricing/buy')) return 'Purchase';
// Default to Active for core product features
return 'Active';
};
const toggleJourneySection = (sectionId: string) => {
setCollapsedJourneySections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
};
// Get emoji icon for journey section
const getJourneySectionIcon = (section: string): string => {
const icons: Record<string, string> = {
'Discovery': '🔍',
'Research': '📚',
'Onboarding': '🎯',
'First Use': '🚀',
'Active': '⚡',
'Support': '💡',
'Purchase': '💳'
};
return icons[section] || '📋';
};
// Get phase status based on overall item status
const getPhaseStatus = (itemStatus: string, phase: 'scope' | 'design' | 'code'): 'built' | 'in_progress' | 'missing' => {
if (itemStatus === 'built') return 'built';
if (itemStatus === 'missing') return 'missing';
// If in_progress, show progression through phases
if (phase === 'scope') return 'built';
if (phase === 'design') return 'in_progress';
return 'missing';
};
// Render status badge
const renderStatusBadge = (status: 'built' | 'in_progress' | 'missing') => {
if (status === 'built') {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs font-medium">
<CheckCircle2 className="h-3 w-3" />
Done
</span>
);
}
if (status === 'in_progress') {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-medium">
<Clock className="h-3 w-3" />
Started
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs font-medium">
<Circle className="h-3 w-3" />
To-do
</span>
);
};
const loadTimelineData = useCallback(async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
const result = await response.json();
// Check if the response is an error
if (result.error) {
console.error('API Error:', result.error, result.details);
alert(`Failed to load timeline: ${result.details || result.error}`);
return;
}
setData(result);
} catch (error) {
console.error('Error loading timeline:', error);
alert('Failed to load timeline data. Check console for details.');
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadTimelineData();
}, [loadTimelineData]);
const regeneratePlan = async () => {
if (!confirm('Regenerate the plan? This will analyze your project and create a fresh MVP checklist.')) {
return;
}
try {
setRegenerating(true);
const response = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to regenerate plan');
}
// Reload the timeline data
await loadTimelineData();
} catch (error) {
console.error('Error regenerating plan:', error);
alert('Failed to regenerate plan. Check console for details.');
} finally {
setRegenerating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!data) {
return <div className="p-8 text-center text-muted-foreground">No timeline data available</div>;
}
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Quick Stats</h3>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Total Items</span>
<span className="font-medium">{data.summary.totalWorkItems}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Built</span>
<span className="font-medium text-green-600">{data.summary.built}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">In Progress</span>
<span className="font-medium text-blue-600">{data.summary.withActivity - data.summary.built}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">To Build</span>
<span className="font-medium text-gray-600">{data.summary.missing}</span>
</div>
</div>
</div>
</div>
</CollapsibleSidebar>
{/* Main Content */}
<div className="flex-1 flex flex-col p-4 space-y-3 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">MVP Checklist</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{data.summary.built} of {data.summary.totalWorkItems} pages built
{data.summary.withActivity} with development activity
</p>
</div>
<div className="flex gap-3 items-center">
{/* View Mode Switcher */}
<div className="flex items-center border rounded-lg p-1">
<Button
variant={viewMode === 'touchpoints' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('touchpoints')}
className="gap-2 h-7"
>
<Eye className="h-4 w-4" />
Touchpoints
</Button>
<Button
variant={viewMode === 'technical' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('technical')}
className="gap-2 h-7"
>
<Cog className="h-4 w-4" />
Technical
</Button>
<Button
variant={viewMode === 'journey' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('journey')}
className="gap-2 h-7"
>
<GitBranch className="h-4 w-4" />
Journey
</Button>
</div>
{/* Regenerate Button */}
<Button
variant="outline"
size="sm"
onClick={regeneratePlan}
disabled={regenerating}
className="gap-2"
>
{regenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Regenerating...
</>
) : (
<>
<RefreshCw className="h-4 w-4" />
Regenerate Plan
</>
)}
</Button>
{/* Summary Stats */}
<div className="text-xs px-3 py-1 bg-green-100 text-green-800 rounded">
{data.summary.built} Built
</div>
<div className="text-xs px-3 py-1 bg-gray-100 text-gray-800 rounded">
{data.summary.missing} To Build
</div>
</div>
</div>
{/* Touchpoints View - What users see and engage with */}
{viewMode === 'touchpoints' && (
<Card className="flex-1 overflow-hidden flex flex-col p-0">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm text-muted-foreground">Everything users see and engage with - screens, features, social posts, blogs, invites, and all customer-facing elements.</p>
</div>
<div className="overflow-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Touchpoint</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.workItems.filter(item => isTouchpoint(item)).map((item, index) => (
<tr
key={item.id}
className="hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
newSet.add(item.id);
}
return newSet;
});
}}
>
<td className="px-4 py-3 text-sm text-muted-foreground">
{getWorkItemType(item)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{item.status === 'built' ? (
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
) : item.status === 'in_progress' ? (
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
) : (
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div>
<div className="text-sm font-medium">{item.title}</div>
{expandedItems.has(item.id) && (
<div className="mt-2 space-y-1">
{item.requirements.map((req) => (
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
{req.status === 'built' ? (
<CheckCircle2 className="h-3 w-3 text-green-600" />
) : (
<Circle className="h-3 w-3 text-gray-400" />
)}
<span>{req.text}</span>
</div>
))}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground">
{item.assigned || data?.projectCreator || 'You'}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.sessionsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.commitsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Journey View - Customer lifecycle stages */}
{viewMode === 'journey' && (
<Card className="flex-1 overflow-auto p-0">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm text-muted-foreground">Customer lifecycle journey from discovery to purchase - organizing all touchpoints and technical components by user stage.</p>
</div>
<div className="divide-y">
{/* Journey Sections - Customer Lifecycle */}
{['Discovery', 'Research', 'Onboarding', 'First Use', 'Active', 'Support', 'Purchase'].map(sectionName => {
const sectionItems = data.workItems.filter(item => getJourneySection(item) === sectionName);
if (sectionItems.length === 0) return null;
const sectionStats = {
done: sectionItems.filter(i => i.status === 'built').length,
started: sectionItems.filter(i => i.status === 'in_progress').length,
todo: sectionItems.filter(i => i.status === 'missing').length,
total: sectionItems.length
};
const isCollapsed = collapsedJourneySections.has(sectionName);
return (
<div key={sectionName}>
{/* Section Header */}
<div
className="bg-muted/30 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors flex items-center justify-between sticky top-0 z-10"
onClick={() => toggleJourneySection(sectionName)}
>
<div className="flex items-center gap-3">
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-lg">{getJourneySectionIcon(sectionName)}</span>
<h3 className="font-semibold text-base">{sectionName}</h3>
<span className="text-xs text-muted-foreground">
{sectionStats.done}/{sectionStats.total} complete
</span>
</div>
<div className="flex gap-2 text-xs">
{sectionStats.done > 0 && (
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
{sectionStats.done} done
</span>
)}
{sectionStats.started > 0 && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
{sectionStats.started} started
</span>
)}
{sectionStats.todo > 0 && (
<span className="px-2 py-1 bg-gray-100 text-gray-800 rounded">
{sectionStats.todo} to-do
</span>
)}
</div>
</div>
{/* Section Items */}
{!isCollapsed && (
<div className="divide-y">
{sectionItems.map(item => (
<div key={item.id} className="px-4 py-3 hover:bg-accent/30 transition-colors">
<div
className="flex items-start justify-between cursor-pointer"
onClick={() => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
newSet.add(item.id);
}
return newSet;
});
}}
>
<div className="flex items-start gap-3 flex-1">
{/* Status Icon */}
{item.status === 'built' ? (
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
) : item.status === 'in_progress' ? (
<Clock className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
) : (
<Circle className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
{/* Title and Type */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{item.title}</span>
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
{getWorkItemType(item)}
</span>
</div>
{/* Phase Status */}
<div className="flex gap-2 mt-2">
<div className="text-xs">
<span className="text-muted-foreground">Spec:</span>{' '}
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
</div>
<div className="text-xs">
<span className="text-muted-foreground">Design:</span>{' '}
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
</div>
<div className="text-xs">
<span className="text-muted-foreground">Code:</span>{' '}
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
</div>
</div>
{/* Expanded Requirements */}
{expandedItems.has(item.id) && (
<div className="mt-3 space-y-1 pl-4 border-l-2 border-gray-200">
<p className="text-xs font-semibold text-muted-foreground mb-2">Requirements:</p>
{item.requirements.map((req) => (
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground">
{req.status === 'built' ? (
<CheckCircle2 className="h-3 w-3 text-green-600" />
) : (
<Circle className="h-3 w-3 text-gray-400" />
)}
<span>{req.text}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Right Side Stats */}
<div className="flex items-start gap-4 text-xs text-muted-foreground">
<div className="text-center">
<div className="font-medium">Sessions</div>
<div className={item.sessionsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.sessionsCount}</div>
</div>
<div className="text-center">
<div className="font-medium">Commits</div>
<div className={item.commitsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.commitsCount}</div>
</div>
<div className="text-center min-w-[60px]">
<div className="font-medium">Cost</div>
<div>{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</Card>
)}
{/* Technical View - Infrastructure that powers everything */}
{viewMode === 'technical' && (
<Card className="flex-1 overflow-hidden flex flex-col p-0">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm text-muted-foreground">Technical infrastructure that powers the product - APIs, backend services, authentication, and system integrations.</p>
</div>
<div className="overflow-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Technical Component</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.workItems.filter(item => isTechnical(item)).map((item, index) => (
<tr
key={item.id}
className="hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
newSet.add(item.id);
}
return newSet;
});
}}
>
<td className="px-4 py-3 text-sm text-muted-foreground">
{getWorkItemType(item)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{item.status === 'built' ? (
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
) : item.status === 'in_progress' ? (
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
) : (
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div>
<div className="text-sm font-medium">{item.title}</div>
{expandedItems.has(item.id) && (
<div className="mt-2 space-y-1">
{item.requirements.map((req) => (
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
{req.status === 'built' ? (
<CheckCircle2 className="h-3 w-3 text-green-600" />
) : (
<Circle className="h-3 w-3 text-gray-400" />
)}
<span>{req.text}</span>
</div>
))}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground">
{item.assigned || data?.projectCreator || 'You'}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.sessionsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.commitsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
{/* End Main Content */}
</div>
);
}

View File

@@ -0,0 +1,459 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
// Maps each PRD section to the discovery phase that populates it
const PRD_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
];
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
return String(v);
}
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
const [expanded, setExpanded] = useState(false);
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
return (
<div style={{
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
border: "1px solid #e8e4dc", overflow: "hidden",
}}>
<button
onClick={() => setExpanded(e => !e)}
style={{
width: "100%", textAlign: "left", padding: "10px 14px",
background: "none", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "space-between",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
{phase.summary}
</span>
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && entries.length > 0 && (
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
{entries.map(([k, v]) => (
<div key={k} style={{ marginTop: 10 }}>
<div style={{
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
}}>
{k.replace(/_/g, " ")}
</div>
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
{formatValue(v)}
</div>
</div>
))}
</div>
)}
</div>
);
}
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
interface ArchInfra { name: string; reason: string }
interface ArchPackage { name: string; description: string }
interface ArchIntegration { name: string; required?: boolean; notes?: string }
interface Architecture {
productName?: string;
productType?: string;
summary?: string;
apps?: ArchApp[];
packages?: ArchPackage[];
infrastructure?: ArchInfra[];
integrations?: ArchIntegration[];
designSurfaces?: string[];
riskNotes?: string[];
}
function ArchitectureView({ arch }: { arch: Architecture }) {
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
{children}
</div>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
);
const Tag = ({ label }: { label: string }) => (
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
);
return (
<div style={{ maxWidth: 760 }}>
{arch.summary && (
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
{arch.summary}
</div>
)}
{(arch.apps ?? []).length > 0 && (
<Section title="Applications">
{arch.apps!.map(a => (
<Card key={a.name}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
{a.tech?.map(t => <Tag key={t} label={t} />)}
{a.screens && a.screens.length > 0 && (
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
)}
</Card>
))}
</Section>
)}
{(arch.packages ?? []).length > 0 && (
<Section title="Shared Packages">
{arch.packages!.map(p => (
<Card key={p.name}>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
</div>
</Card>
))}
</Section>
)}
{(arch.infrastructure ?? []).length > 0 && (
<Section title="Infrastructure">
{arch.infrastructure!.map(i => (
<Card key={i.name}>
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
</Card>
))}
</Section>
)}
{(arch.integrations ?? []).length > 0 && (
<Section title="Integrations">
{arch.integrations!.map(i => (
<Card key={i.name}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
</div>
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
</Card>
))}
</Section>
)}
{(arch.riskNotes ?? []).length > 0 && (
<Section title="Architectural Risks">
{arch.riskNotes!.map((r, i) => (
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}></span>
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
</div>
))}
</Section>
)}
</div>
);
}
export default function PRDPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
const [archGenerating, setArchGenerating] = useState(false);
const [archError, setArchError] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
]).then(([projectData, phaseData]) => {
setPrd(projectData?.project?.prd ?? null);
setArchitecture(projectData?.project?.architecture ?? null);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
const router = useRouter();
const handleGenerateArchitecture = async () => {
setArchGenerating(true);
setArchError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Generation failed");
setArchitecture(data.architecture);
setActiveTab("architecture");
} catch (e) {
setArchError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setArchGenerating(false);
}
};
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
...s,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
}));
const doneCount = sections.filter(s => s.isDone).length;
const totalPct = Math.round((doneCount / sections.length) * 100);
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
const tabs = [
{ id: "prd" as const, label: "PRD", available: true },
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
];
return (
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Tab bar — only when at least one doc exists */}
{(prd || architecture) && (
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
{tabs.map(t => {
const isActive = activeTab === t.id;
return (
<button
key={t.id}
onClick={() => t.available && setActiveTab(t.id)}
disabled={!t.available}
style={{
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
background: isActive ? "#1a1a1a" : "transparent",
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "all 0.1s",
}}
>
{t.label}
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}></span>}
</button>
);
})}
</div>
)}
{/* Next step banner — PRD done but no architecture yet */}
{prd && !architecture && activeTab === "prd" && (
<div style={{
marginBottom: 24, padding: "18px 22px",
background: "#1a1a1a", borderRadius: 10,
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
Next: Generate technical architecture
</div>
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
</div>
{archError && (
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}> {archError}</div>
)}
</div>
<button
onClick={handleGenerateArchitecture}
disabled={archGenerating}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: archGenerating ? "#4a4640" : "#fff",
color: archGenerating ? "#a09a90" : "#1a1a1a",
fontSize: "0.82rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: archGenerating ? "default" : "pointer",
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
transition: "opacity 0.15s",
}}
>
{archGenerating && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #60606040", borderTopColor: "#a09a90",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
</button>
</div>
)}
{/* Architecture tab */}
{activeTab === "architecture" && architecture && (
<ArchitectureView arch={architecture} />
)}
{/* PRD tab — finalized */}
{activeTab === "prd" && prd && (
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Product Requirements
</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
PRD complete
</span>
</div>
<div style={{
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
padding: "28px 32px", lineHeight: 1.8,
fontSize: "0.88rem", color: "#2a2824",
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{prd}
</div>
</div>
)}
{/* PRD tab — section progress (no finalized PRD yet) */}
{activeTab === "prd" && !prd && (
/* ── Section progress view ── */
<div style={{ maxWidth: 680 }}>
{/* Progress bar */}
<div style={{
display: "flex", alignItems: "center", gap: 16,
padding: "16px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
}}>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
}}>
{totalPct}%
</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
<div style={{
height: "100%", borderRadius: 2,
width: `${totalPct}%`, background: "#1a1a1a",
transition: "width 0.6s ease",
}} />
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{doneCount}/{sections.length} sections
</span>
</div>
{/* Sections */}
{sections.map((s, i) => (
<div
key={s.id}
style={{
padding: "14px 18px", marginBottom: 6,
background: "#fff", borderRadius: 10,
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
animationDelay: `${i * 0.04}s`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Status icon */}
<div style={{
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700,
color: s.isDone ? "#2e7d32" : "#c5c0b8",
}}>
{s.isDone ? "✓" : "○"}
</div>
<span style={{
flex: 1, fontSize: "0.84rem",
color: s.isDone ? "#1a1a1a" : "#a09a90",
fontWeight: s.isDone ? 500 : 400,
}}>
{s.label}
</span>
{s.isDone && s.savedPhase && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
}}>
saved
</span>
)}
{!s.isDone && !s.phaseId && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#b5b0a6", padding: "2px 7px",
}}>
generated
</span>
)}
</div>
{/* Expandable phase data */}
{s.isDone && s.savedPhase && (
<PhaseDataCard phase={s.savedPhase} />
)}
{/* Pending hint */}
{!s.isDone && (
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
{s.phaseId
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
: "Will be generated when PRD is finalized"}
</div>
)}
</div>
))}
{doneCount === 0 && (
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
Continue chatting with Vibn saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -1,179 +0,0 @@
"use client";
import { useParams, usePathname } from "next/navigation";
import {
Code2,
Globe,
Server,
MessageSquare,
ChevronRight,
} from "lucide-react";
import {
PageTemplate,
PageSection,
PageCard,
PageGrid,
} from "@/components/layout/page-template";
const PRODUCT_NAV_ITEMS = [
{ title: "Code", icon: Code2, href: "/code" },
{ title: "Website", icon: Globe, href: "/product#website" },
{ title: "Chat Agent", icon: MessageSquare, href: "/product#agent" },
{ title: "Deployment", icon: Server, href: "/product#deployment" },
];
export default function ProductPage() {
const params = useParams();
const pathname = usePathname();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const sidebarItems = PRODUCT_NAV_ITEMS.map((item) => {
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
return {
...item,
href: fullHref,
isActive: pathname === fullHref || pathname.startsWith(fullHref),
};
});
return (
<PageTemplate
sidebar={{
items: sidebarItems,
}}
>
{/* Quick Navigation Cards */}
<PageSection>
<PageGrid cols={2}>
{PRODUCT_NAV_ITEMS.map((item) => {
const Icon = item.icon;
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
return (
<a key={item.href} href={fullHref}>
<PageCard hover>
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold mb-1">{item.title}</h3>
<p className="text-sm text-muted-foreground">
{item.title === "Code" &&
"Browse codebase, manage repositories"}
{item.title === "Website" &&
"Marketing site, landing pages"}
{item.title === "Chat Agent" &&
"Conversational AI interface"}
{item.title === "Deployment" &&
"Hosting, CI/CD, environments"}
</p>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
</PageCard>
</a>
);
})}
</PageGrid>
</PageSection>
{/* Code Section */}
<PageSection
title="Code"
description="Your application codebase"
>
<PageCard>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
<Code2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Browse Repository</p>
<p className="text-sm text-muted-foreground">
View files, commits, and code structure
</p>
</div>
</div>
<a href={`/${workspace}/project/${projectId}/code`}>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</a>
</div>
</PageCard>
</PageSection>
{/* Website Section */}
<PageSection
title="Website"
description="Marketing site and landing pages"
>
<PageCard>
<div className="text-center py-8 text-muted-foreground">
<Globe className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
Manage your marketing website and landing pages
</p>
</div>
</PageCard>
</PageSection>
{/* Chat Agent Section */}
<PageSection
title="Chat Agent"
description="Conversational AI interface"
>
<PageCard>
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
Configure and manage your AI chat agent
</p>
</div>
</PageCard>
</PageSection>
{/* Deployment Section */}
<PageSection
title="Deployment"
description="Hosting and CI/CD"
>
<PageGrid cols={3}>
<PageCard>
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
<Server className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold mb-1">Production</h3>
<p className="text-sm text-muted-foreground mb-2">Live</p>
<p className="text-xs text-muted-foreground">vercel.app</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
<Server className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold mb-1">Staging</h3>
<p className="text-sm text-muted-foreground mb-2">Preview</p>
<p className="text-xs text-muted-foreground">staging.vercel.app</p>
</div>
</PageCard>
<PageCard>
<div className="text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
<Server className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold mb-1">Development</h3>
<p className="text-sm text-muted-foreground mb-2">Local</p>
<p className="text-xs text-muted-foreground">localhost:3000</p>
</div>
</PageCard>
</PageGrid>
</PageSection>
</PageTemplate>
);
}

View File

@@ -1,227 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ListChecks, Clock, DollarSign, GitBranch, ExternalLink, User } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
// Mock data
const PROGRESS_ITEMS = [
{
id: 1,
title: "Implemented Product Vision page with file upload",
description: "Created dynamic layout system with file upload capabilities for ChatGPT exports",
contributor: "Mark Henderson",
date: "2025-11-11",
time: "2h 15m",
tokens: 45000,
cost: 0.68,
github_link: "https://github.com/user/repo/commit/abc123",
type: "feature"
},
{
id: 2,
title: "Updated left rail navigation structure",
description: "Refactored navigation to remove rounded edges and improve active state",
contributor: "Mark Henderson",
date: "2025-11-11",
time: "45m",
tokens: 12000,
cost: 0.18,
github_link: "https://github.com/user/repo/commit/def456",
type: "improvement"
},
{
id: 3,
title: "Added section summaries to Overview page",
description: "Created cards for Product Vision, Progress, UI UX, Code, Deployment, and Automation",
contributor: "Mark Henderson",
date: "2025-11-11",
time: "1h 30m",
tokens: 32000,
cost: 0.48,
github_link: "https://github.com/user/repo/commit/ghi789",
type: "feature"
},
{
id: 4,
title: "Fixed database connection issues",
description: "Resolved connection pooling and error handling in API routes",
contributor: "Mark Henderson",
date: "2025-11-10",
time: "30m",
tokens: 8000,
cost: 0.12,
github_link: "https://github.com/user/repo/commit/jkl012",
type: "fix"
},
];
export default async function ProgressPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;
return (
<>
<PageHeader
projectId={projectId}
projectName="AI Proxy"
projectEmoji="🤖"
pageName="Progress"
/>
<div className="flex h-full flex-col overflow-auto">
{/* Hero Section */}
<div className="border-b bg-gradient-to-r from-green-500/5 to-green-500/10 p-8">
<div className="mx-auto max-w-6xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
<ListChecks className="h-6 w-6 text-green-600" />
</div>
<div>
<h1 className="text-3xl font-bold">Progress</h1>
<p className="text-muted-foreground">Development activity and velocity</p>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 p-6">
<div className="mx-auto max-w-6xl space-y-6">
{/* Summary Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-3">
<div className="text-xs text-muted-foreground mb-1">Total Items</div>
<div className="text-2xl font-bold">{PROGRESS_ITEMS.length}</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="text-xs text-muted-foreground mb-1">Total Time</div>
<div className="text-2xl font-bold">5h 0m</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="text-xs text-muted-foreground mb-1">Total Cost</div>
<div className="text-2xl font-bold">$1.46</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="text-xs text-muted-foreground mb-1">Total Tokens</div>
<div className="text-2xl font-bold">97K</div>
</CardContent>
</Card>
</div>
{/* Progress List */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Development Activity</CardTitle>
<CardDescription>Sorted by latest</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
Latest
</Button>
<Button variant="ghost" size="sm">
Cost
</Button>
<Button variant="ghost" size="sm">
Time
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{PROGRESS_ITEMS.map((item) => (
<div
key={item.id}
className="flex flex-col gap-3 rounded-lg border p-4 hover:bg-accent/50 transition-colors"
>
{/* Header Row */}
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold">{item.title}</h3>
<Badge variant={
item.type === 'feature' ? 'default' :
item.type === 'fix' ? 'destructive' :
'secondary'
} className="text-xs">
{item.type}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
</div>
{/* Metadata Row */}
<div className="flex items-center gap-6 text-sm">
{/* Contributor */}
<div className="flex items-center gap-1.5">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{item.contributor}</span>
</div>
{/* Time */}
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{item.time}</span>
</div>
{/* Tokens */}
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">{item.tokens.toLocaleString()} tokens</span>
</div>
{/* Cost */}
<div className="flex items-center gap-1.5">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">${item.cost.toFixed(2)}</span>
</div>
{/* GitHub Link */}
<div className="ml-auto">
<Button variant="ghost" size="sm" className="h-7" asChild>
<a href={item.github_link} target="_blank" rel="noopener noreferrer">
<GitBranch className="mr-1.5 h-3.5 w-3.5" />
Commit
<ExternalLink className="ml-1.5 h-3 w-3" />
</a>
</Button>
</div>
</div>
{/* Date */}
<div className="text-xs text-muted-foreground">
{new Date(item.date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,58 +0,0 @@
"use client";
import {
LayoutGrid,
Settings,
Users,
BarChart,
Box,
} from "lucide-react";
import {
PageTemplate,
} from "@/components/layout/page-template";
import { Badge } from "@/components/ui/badge";
const SANDBOX_NAV_ITEMS = [
{ title: "Nav Item 1", icon: LayoutGrid, href: "#item1" },
{ title: "Nav Item 2", icon: Box, href: "#item2" },
{ title: "Nav Item 3", icon: Users, href: "#item3" },
{ title: "Nav Item 4", icon: Settings, href: "#item4" },
];
export default function SandboxPage() {
// Mock navigation items for the sidebar
const sidebarItems = SANDBOX_NAV_ITEMS.map((item) => ({
...item,
href: item.href,
isActive: item.title === "Nav Item 1", // Mock active state
badge: item.title === "Nav Item 2" ? "New" : undefined,
}));
return (
<PageTemplate
sidebar={{
items: sidebarItems,
customContent: (
<div className="space-y-4">
<div className="px-2 py-1 bg-dashed border border-dashed border-muted-foreground/30 rounded text-xs text-center text-muted-foreground uppercase tracking-wider">
Custom Component Area
</div>
{/* Mock Custom Component Example */}
<div className="space-y-2 opacity-70">
<h3 className="text-sm font-medium">Example Widget</h3>
<div className="p-3 rounded bg-muted/50 text-xs text-muted-foreground">
This area fills the remaining sidebar height and can hold any custom React component (checklists, filters, etc).
</div>
</div>
</div>
),
}}
>
{/* Empty Main Content Area */}
<div className="border-2 border-dashed border-muted-foreground/20 rounded-lg h-[400px] flex items-center justify-center text-muted-foreground">
<p>Main Content Area (Empty)</p>
</div>
</PageTemplate>
);
}

View File

@@ -1,209 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Activity, Clock, DollarSign, MessageSquare } from "lucide-react";
import type { Session, DashboardStats } from "@/lib/types";
import { PageHeader } from "@/components/layout/page-header";
async function getSessions(projectId: string): Promise<Session[]> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/sessions?projectId=${projectId}&limit=20`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error('Failed to fetch sessions');
return res.json();
} catch (error) {
console.error('Error fetching sessions:', error);
return [];
}
}
async function getStats(projectId: string): Promise<DashboardStats> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/stats?projectId=${projectId}`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json();
} catch (error) {
return {
totalSessions: 0,
totalCost: 0,
totalTokens: 0,
totalFeatures: 0,
completedFeatures: 0,
totalDuration: 0,
};
}
}
export default async function SessionsPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
const [sessions, stats] = await Promise.all([
getSessions(projectId),
getStats(projectId),
]);
return (
<>
<PageHeader
projectId={projectId}
projectName="Project"
projectEmoji="📦"
pageName="Sessions"
/>
<div className="flex h-full flex-col overflow-auto">
{/* Stats Section */}
<div className="border-b bg-card/50 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Sessions</h1>
<p className="text-sm text-muted-foreground">
Track all your AI coding sessions
</p>
</div>
<Button>
<Activity className="mr-2 h-4 w-4" />
New Session
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl space-y-6">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Sessions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.totalSessions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Duration
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{Math.round(stats.totalDuration / 60)}h
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Cost
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
${stats.totalCost.toFixed(2)}
</div>
</CardContent>
</Card>
</div>
{/* Sessions List */}
<Card>
<CardHeader>
<CardTitle>Recent Sessions</CardTitle>
<CardDescription>
Your AI coding activity with conversation history
</CardDescription>
</CardHeader>
<CardContent>
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4 rounded-full bg-muted p-3">
<Activity className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No sessions yet</h3>
<p className="text-sm text-center text-muted-foreground max-w-sm">
Start coding with AI and your sessions will appear here
</p>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-lg border p-4 hover:bg-accent/50 transition-colors cursor-pointer"
>
<div className="flex items-start gap-4">
<div className="rounded-full bg-primary/10 p-2">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-medium">
{session.summary || `Session ${session.session_id.substring(0, 8)}...`}
</h3>
<div className="mt-1 flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{session.duration_minutes} min
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{session.message_count} messages
</span>
{session.estimated_cost_usd && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
${session.estimated_cost_usd.toFixed(3)}
</span>
)}
</div>
<div className="mt-2 flex gap-2">
{session.primary_ai_model && (
<Badge variant="outline" className="text-xs">
{session.primary_ai_model}
</Badge>
)}
{session.ide_name && (
<Badge variant="outline" className="text-xs">
{session.ide_name}
</Badge>
)}
{session.github_branch && (
<Badge variant="secondary" className="text-xs">
{session.github_branch}
</Badge>
)}
</div>
</div>
</div>
<Button variant="ghost" size="sm">
View Details
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,208 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Activity, Clock, DollarSign, MessageSquare } from "lucide-react";
import type { Session, DashboardStats } from "@/lib/types";
import { PageHeader } from "@/components/layout/page-header";
async function getSessions(projectId: string): Promise<Session[]> {
try {
const res = await fetch(
`http://localhost:3000/api/sessions?projectId=${projectId}&limit=20`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error('Failed to fetch sessions');
return res.json();
} catch (error) {
console.error('Error fetching sessions:', error);
return [];
}
}
async function getStats(projectId: string): Promise<DashboardStats> {
try {
const res = await fetch(
`http://localhost:3000/api/stats?projectId=${projectId}`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json();
} catch (error) {
return {
totalSessions: 0,
totalCost: 0,
totalTokens: 0,
totalFeatures: 0,
completedFeatures: 0,
totalDuration: 0,
};
}
}
export default async function SessionsPage({
params,
}: {
params: { projectId: string };
}) {
const [sessions, stats] = await Promise.all([
getSessions(params.projectId),
getStats(params.projectId),
]);
return (
<>
<PageHeader
projectId={params.projectId}
projectName="AI Proxy"
projectEmoji="🤖"
pageName="Sessions"
/>
<div className="flex h-full flex-col overflow-auto">
{/* Stats Section */}
<div className="border-b bg-card/50 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Sessions</h1>
<p className="text-sm text-muted-foreground">
Track all your AI coding sessions
</p>
</div>
<Button>
<Activity className="mr-2 h-4 w-4" />
New Session
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl space-y-6">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Sessions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.totalSessions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Duration
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{Math.round(stats.totalDuration / 60)}h
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Cost
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
${stats.totalCost.toFixed(2)}
</div>
</CardContent>
</Card>
</div>
{/* Sessions List */}
<Card>
<CardHeader>
<CardTitle>Recent Sessions</CardTitle>
<CardDescription>
Your AI coding activity with conversation history
</CardDescription>
</CardHeader>
<CardContent>
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4 rounded-full bg-muted p-3">
<Activity className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No sessions yet</h3>
<p className="text-sm text-center text-muted-foreground max-w-sm">
Start coding with AI and your sessions will appear here
</p>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-lg border p-4 hover:bg-accent/50 transition-colors cursor-pointer"
>
<div className="flex items-start gap-4">
<div className="rounded-full bg-primary/10 p-2">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-medium">
{session.summary || `Session ${session.session_id.substring(0, 8)}...`}
</h3>
<div className="mt-1 flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{session.duration_minutes} min
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{session.message_count} messages
</span>
{session.estimated_cost_usd && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
${session.estimated_cost_usd.toFixed(3)}
</span>
)}
</div>
<div className="mt-2 flex gap-2">
{session.primary_ai_model && (
<Badge variant="outline" className="text-xs">
{session.primary_ai_model}
</Badge>
)}
{session.ide_name && (
<Badge variant="outline" className="text-xs">
{session.ide_name}
</Badge>
)}
{session.github_branch && (
<Badge variant="secondary" className="text-xs">
{session.github_branch}
</Badge>
)}
</div>
</div>
</div>
<Button variant="ghost" size="sm">
View Details
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,357 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Save, FolderOpen, AlertCircle } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { db, auth } from "@/lib/firebase/config";
import { doc, getDoc, updateDoc, serverTimestamp } from "firebase/firestore";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
interface Project {
id: string;
name: string;
productName: string;
productVision?: string;
workspacePath?: string;
workspaceName?: string;
githubRepo?: string;
chatgptUrl?: string;
projectType: string;
status: string;
giteaRepo?: string;
giteaRepoUrl?: string;
status?: string;
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
}}>
{children}
</div>
);
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>
{children}
</div>
);
}
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "22px", marginBottom: 12, boxShadow: "0 1px 2px #1a1a1a05", ...style,
}}>
{children}
</div>
);
}
export default function ProjectSettingsPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [orphanedSessionsCount, setOrphanedSessionsCount] = useState(0);
// Form state
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [productName, setProductName] = useState("");
const [productVision, setProductVision] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
const userInitial = session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
const userName = session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "You";
useEffect(() => {
const fetchProject = async () => {
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
router.push('/auth');
return;
}
const projectDoc = await getDoc(doc(db, 'projects', projectId));
if (!projectDoc.exists()) {
toast.error('Project not found');
router.push(`/${workspace}/projects`);
return;
}
const projectData = projectDoc.data() as Project;
setProject({ ...projectData, id: projectDoc.id });
// Set form values
setProductName(projectData.productName);
setProductVision(projectData.productVision || "");
setWorkspacePath(projectData.workspacePath || "");
// Check for orphaned sessions from old workspace path
if (projectData.workspacePath) {
// This would require checking sessions - we'll implement this in the API
// For now, just show the UI
}
} catch (err: any) {
console.error('Error fetching project:', err);
toast.error('Failed to load project');
} finally {
setLoading(false);
}
};
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
fetchProject();
} else {
router.push('/auth');
}
});
return () => unsubscribe();
}, [projectId, workspace, router]);
fetch(`/api/projects/${projectId}`)
.then((r) => r.json())
.then((d) => {
const p = d.project;
setProject(p);
setProductName(p?.productName ?? "");
setProductVision(p?.productVision ?? "");
})
.catch(() => toast.error("Failed to load project"))
.finally(() => setLoading(false));
}, [projectId]);
const handleSave = async () => {
setSaving(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
return;
}
// Get the directory name from the path
const workspaceName = workspacePath ? workspacePath.split('/').pop() || '' : '';
await updateDoc(doc(db, 'projects', projectId), {
productName,
productVision,
workspacePath,
workspaceName,
updatedAt: serverTimestamp(),
const res = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productName, productVision }),
});
toast.success('Project settings saved!');
// Refresh project data
const projectDoc = await getDoc(doc(db, 'projects', projectId));
if (projectDoc.exists()) {
setProject({ ...projectDoc.data() as Project, id: projectDoc.id });
if (res.ok) {
toast.success("Saved");
setProject((p) => p ? { ...p, productName, productVision } : p);
} else {
toast.error("Failed to save");
}
} catch (error) {
console.error('Error saving project:', error);
toast.error('Failed to save settings');
} catch {
toast.error("An error occurred");
} finally {
setSaving(false);
}
};
const handleSelectDirectory = async () => {
const handleDelete = async () => {
if (!confirmDelete) { setConfirmDelete(true); return; }
setDeleting(true);
try {
// Check if File System Access API is supported
if ('showDirectoryPicker' in window) {
const dirHandle = await (window as any).showDirectoryPicker({
mode: 'read',
});
if (dirHandle?.name) {
// Provide a path hint (browsers don't expose full paths for security)
const pathHint = `~/projects/${dirHandle.name}`;
setWorkspacePath(pathHint);
toast.info('Update the path to match your actual folder location', {
description: 'You can get the full path from Finder/Explorer or your terminal'
});
}
const res = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
if (res.ok) {
toast.success("Project deleted");
router.push(`/${workspace}/projects`);
} else {
toast.error('Directory picker not supported in this browser', {
description: 'Please enter the path manually or use Chrome/Edge'
});
}
} catch (error: any) {
// User cancelled or denied permission
if (error.name !== 'AbortError') {
console.error('Error selecting directory:', error);
toast.error('Failed to select directory');
toast.error("Failed to delete project");
}
} catch {
toast.error("An error occurred");
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!project) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Project not found</p>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
</div>
);
}
return (
<div className="flex h-full flex-col overflow-auto">
{/* Header */}
<div className="border-b px-6 py-4">
<h1 className="text-2xl font-bold">Project Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your project configuration and workspace settings
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 480 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
Project Settings
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
Configure {project?.productName ?? "this project"}
</p>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-4xl space-y-6">
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General Information</CardTitle>
<CardDescription>
Basic details about your project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="productName">Product Name</Label>
<Input
id="productName"
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="My Awesome Product"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productVision">Product Vision</Label>
<Textarea
id="productVision"
value={productVision}
onChange={(e) => setProductVision(e.target.value)}
placeholder="Describe what you're building and who it's for..."
rows={4}
/>
</div>
</CardContent>
</Card>
{/* Workspace Settings */}
<Card>
<CardHeader>
<CardTitle>Workspace Path</CardTitle>
<CardDescription>
The local directory where you're coding this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Why update this?</AlertTitle>
<AlertDescription>
If you renamed your project folder or moved it to a different location,
update the path here so Vibn can correctly track your coding sessions.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="workspacePath">Local Workspace Path</Label>
<div className="flex gap-2">
<Input
id="workspacePath"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder="/Users/you/projects/my-project"
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSelectDirectory}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
💡 <strong>Tip:</strong> Right-click your project folder → Get Info (Mac) or Properties (Windows) to copy the full path
</p>
</div>
{project.workspacePath && workspacePath !== project.workspacePath && (
<Alert className="border-orange-500/50 bg-orange-500/10">
<AlertCircle className="h-4 w-4 text-orange-600" />
<AlertTitle>Path Changed</AlertTitle>
<AlertDescription>
You're changing the workspace path from <code className="text-xs bg-muted px-1 py-0.5 rounded">{project.workspacePath}</code> to <code className="text-xs bg-muted px-1 py-0.5 rounded">{workspacePath}</code>.
<br /><br />
After saving, Vibn will track sessions from the new path. Any existing sessions from the old path will remain associated with this project.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Connected Services */}
<Card>
<CardHeader>
<CardTitle>Connected Services</CardTitle>
<CardDescription>
External integrations for this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">GitHub Repository</p>
<p className="text-sm text-muted-foreground">
{project.githubRepo || 'Not connected'}
</p>
</div>
{project.githubRepo && (
<a
href={`https://github.com/${project.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View on GitHub
</a>
)}
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">ChatGPT Project</p>
<p className="text-sm text-muted-foreground">
{project.chatgptUrl ? 'Connected' : 'Not connected'}
</p>
</div>
{project.chatgptUrl && (
<a
href={project.chatgptUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Open ChatGPT
</a>
)}
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end gap-3 pt-4">
<Button
variant="outline"
onClick={() => router.push(`/${workspace}/project/${projectId}/overview`)}
{/* General */}
<InfoCard>
<SectionLabel>General</SectionLabel>
<FieldLabel>Project name</FieldLabel>
<input
value={productName}
onChange={(e) => setProductName(e.target.value)}
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", marginBottom: 16, boxSizing: "border-box" }}
/>
<FieldLabel>Description</FieldLabel>
<textarea
value={productVision}
onChange={(e) => setProductVision(e.target.value)}
rows={3}
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", resize: "vertical", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
<button
onClick={handleSave}
disabled={saving}
style={{ padding: "8px 20px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: saving ? "not-allowed" : "pointer", opacity: saving ? 0.7 : 1, display: "flex", alignItems: "center", gap: 6 }}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
{saving && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
{saving ? "Saving…" : "Save"}
</button>
</div>
</InfoCard>
{/* Repo */}
{project?.giteaRepoUrl && (
<InfoCard>
<SectionLabel>Repository</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a", fontWeight: 500 }}>{project.giteaRepo}</div>
</div>
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none" }}>
View
</a>
</div>
</InfoCard>
)}
{/* Collaborators */}
<InfoCard>
<SectionLabel>Collaborators</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 600, color: "#8a8478" }}>
{userInitial}
</div>
<span style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a" }}>{userName}</span>
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", background: "#f0ece4" }}>Owner</span>
</div>
<button
style={{ width: "100%", marginTop: 12, padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
onClick={() => toast.info("Team invites coming soon")}
>
+ Invite to project
</button>
</InfoCard>
{/* Export */}
<InfoCard>
<SectionLabel>Export</SectionLabel>
<p style={{ fontSize: "0.82rem", color: "#6b6560", marginBottom: 14, lineHeight: 1.6 }}>
Download your PRD or project data for external use.
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
onClick={() => toast.info("PDF export coming soon")}
>
Export PRD as PDF
</button>
<button
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
onClick={async () => {
const res = await fetch(`/api/projects/${projectId}`);
const data = await res.json();
const blob = new Blob([JSON.stringify(data.project, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `${productName.replace(/\s+/g, "-")}.json`; a.click();
URL.revokeObjectURL(url);
}}
>
Export as JSON
</button>
</div>
</InfoCard>
{/* Danger zone */}
<div style={{ background: "#fff", border: "1px solid #f5d5d5", borderRadius: 10, padding: "20px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#d32f2f" }}>Delete project</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{confirmDelete ? "Click again to confirm — this cannot be undone" : "This action cannot be undone"}
</div>
</div>
<button
onClick={handleDelete}
disabled={deleting}
style={{ padding: "6px 14px", borderRadius: 7, border: "1px solid #f5d5d5", background: confirmDelete ? "#d32f2f" : "#fff", color: confirmDelete ? "#fff" : "#d32f2f", fontSize: "0.72rem", fontWeight: 600, cursor: "pointer", transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6 }}
>
{deleting && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
{confirmDelete ? "Confirm Delete" : "Delete"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,401 +0,0 @@
"use client";
import { use, useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Cog,
Database,
Github,
Globe,
Server,
Code2,
ExternalLink,
Plus,
Loader2,
CheckCircle2,
Circle,
Clock,
Key,
Zap,
} from "lucide-react";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { toast } from "sonner";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
interface WorkItem {
id: string;
title: string;
path: string;
status: "built" | "in_progress" | "missing";
category: string;
sessionsCount: number;
commitsCount: number;
estimatedCost?: number;
}
interface TechResource {
id: string;
name: string;
type: "firebase" | "github" | "domain" | "api";
status: "active" | "inactive";
url?: string;
lastUpdated?: string;
}
export default function TechPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = use(params);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [resources, setResources] = useState<TechResource[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadTechData();
}, [projectId]);
const loadTechData = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
if (response.ok) {
const data = await response.json();
// Filter for technical items only
const techItems = data.workItems.filter((item: WorkItem) =>
isTechnical(item)
);
setWorkItems(techItems);
}
// Mock resources data
setResources([
{
id: "1",
name: "Firebase Project",
type: "firebase",
status: "active",
url: "https://console.firebase.google.com",
lastUpdated: new Date().toISOString(),
},
{
id: "2",
name: "GitHub Repository",
type: "github",
status: "active",
url: "https://github.com",
lastUpdated: new Date().toISOString(),
},
]);
} catch (error) {
console.error("Error loading tech data:", error);
toast.error("Failed to load tech data");
} finally {
setLoading(false);
}
};
const isTechnical = (item: WorkItem): boolean => {
const path = item.path.toLowerCase();
const title = item.title.toLowerCase();
// APIs and backend
if (path.startsWith('/api/')) return true;
if (title.includes(' api') || title.includes('api ')) return true;
// Auth infrastructure
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
// System settings
if (item.category === 'Settings' && title.includes('api')) return true;
return false;
};
const getStatusIcon = (status: string) => {
if (status === "built" || status === "active") return <CheckCircle2 className="h-4 w-4 text-green-600" />;
if (status === "in_progress") return <Clock className="h-4 w-4 text-blue-600" />;
return <Circle className="h-4 w-4 text-gray-400" />;
};
const getResourceIcon = (type: string) => {
switch (type) {
case "firebase":
return <Zap className="h-5 w-5 text-orange-600" />;
case "github":
return <Github className="h-5 w-5 text-gray-900" />;
case "domain":
return <Globe className="h-5 w-5 text-blue-600" />;
case "api":
return <Code2 className="h-5 w-5 text-purple-600" />;
default:
return <Server className="h-5 w-5 text-gray-600" />;
}
};
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Infrastructure</h3>
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Resources</span>
<span className="font-medium">{resources.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Active</span>
<span className="font-medium text-green-600">{resources.filter(r => r.status === 'active').length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Work Items</span>
<span className="font-medium">{workItems.length}</span>
</div>
</div>
</div>
</div>
</CollapsibleSidebar>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="border-b bg-background p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Cog className="h-6 w-6" />
<div>
<h1 className="text-xl font-bold">Tech Infrastructure</h1>
<p className="text-sm text-muted-foreground">
APIs, services, and technical resources
</p>
</div>
</div>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Add Resource
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-6">
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Infrastructure Resources */}
<div>
<h2 className="text-lg font-semibold mb-4">Infrastructure Resources</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{resources.map((resource) => (
<Card key={resource.id} className="hover:bg-accent/30 transition-colors">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getResourceIcon(resource.type)}
<CardTitle className="text-base">{resource.name}</CardTitle>
</div>
{getStatusIcon(resource.status)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Badge variant="secondary" className="text-xs capitalize">
{resource.type}
</Badge>
{resource.lastUpdated && (
<p className="text-xs text-muted-foreground">
Updated {new Date(resource.lastUpdated).toLocaleDateString()}
</p>
)}
{resource.url && (
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => window.open(resource.url, "_blank")}
>
<ExternalLink className="h-3 w-3" />
Open Console
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
<Separator />
{/* Technical Work Items */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Technical Work Items</h2>
<Badge variant="secondary">{workItems.length} items</Badge>
</div>
{workItems.length === 0 ? (
<Card className="p-8 text-center">
<Code2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No technical items yet</h3>
<p className="text-sm text-muted-foreground">
Technical items include APIs, services, and infrastructure
</p>
</Card>
) : (
<div className="space-y-3">
{workItems.map((item) => (
<Card key={item.id} className="p-4 hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{getStatusIcon(item.status)}
<div className="flex-1 space-y-2">
{/* Title and Status */}
<div className="flex items-center gap-2">
<h3 className="font-semibold">{item.title}</h3>
<Badge variant="outline" className="text-xs">
{item.status === "built" ? "Active" : item.status === "in_progress" ? "In Progress" : "Planned"}
</Badge>
</div>
{/* Path */}
<p className="text-sm text-muted-foreground font-mono">
{item.path}
</p>
{/* Stats */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{item.sessionsCount} sessions</span>
<span></span>
<span>{item.commitsCount} commits</span>
{item.estimatedCost && (
<>
<span></span>
<span>${item.estimatedCost.toFixed(2)}</span>
</>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => toast.info("Documentation coming soon")}
>
<Database className="h-4 w-4" />
</Button>
{item.path.startsWith('/api/') && (
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => toast.info("API testing coming soon")}
>
<Code2 className="h-4 w-4" />
Test API
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Quick Links */}
<div>
<h2 className="text-lg font-semibold mb-4">Quick Links</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
<Link href={`/${workspace}/keys`} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">API Keys</p>
<p className="text-xs text-muted-foreground">Manage service credentials</p>
</div>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</Link>
</Card>
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
<a
href="https://console.firebase.google.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-orange-600" />
<div>
<p className="font-medium">Firebase Console</p>
<p className="text-xs text-muted-foreground">Manage database & hosting</p>
</div>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
</Card>
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3">
<Github className="h-5 w-5 text-gray-900" />
<div>
<p className="font-medium">GitHub</p>
<p className="text-xs text-muted-foreground">Code repository & CI/CD</p>
</div>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
</Card>
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
<a
href="https://vercel.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">Deployment</p>
<p className="text-xs text-muted-foreground">Production & preview deploys</p>
</div>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
</Card>
</div>
</div>
</>
)}
</div>
</div>
{/* End Main Content */}
</div>
);
}

View File

@@ -1,736 +0,0 @@
'use client';
import { use, useState, useEffect, useCallback } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, CheckCircle2, Circle, Clock, RefreshCw, Eye, Cog, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
interface WorkItem {
id: string;
title: string;
category: string;
path: string;
status: 'built' | 'missing' | 'in_progress';
priority: string;
assigned?: string;
startDate: string | null;
endDate: string | null;
duration: number;
sessionsCount: number;
commitsCount: number;
totalActivity: number;
estimatedCost?: number;
requirements: Array<{
id: number;
text: string;
status: 'built' | 'missing' | 'in_progress';
}>;
evidence: string[];
note?: string;
}
interface TimelineData {
workItems: WorkItem[];
timeline: {
start: string;
end: string;
totalDays: number;
};
summary: {
totalWorkItems: number;
withActivity: number;
noActivity: number;
built: number;
missing: number;
};
projectCreator?: string;
}
export default function TimelinePlanPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { projectId } = use(params);
const [data, setData] = useState<TimelineData | null>(null);
const [loading, setLoading] = useState(true);
const [regenerating, setRegenerating] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<'touchpoints' | 'technical' | 'journey'>('touchpoints');
const [collapsedJourneySections, setCollapsedJourneySections] = useState<Set<string>>(new Set());
// Map work items to types based on path and category
const getWorkItemType = (item: WorkItem): string => {
// API endpoints are System
if (item.path.startsWith('/api/')) return 'System';
// Flows are Flow
if (item.path.startsWith('flow/')) return 'Flow';
// Auth/OAuth is System
if (item.path.includes('auth') || item.path.includes('oauth')) return 'System';
// Settings is System
if (item.path.includes('settings')) return 'System';
// Marketing/Content pages
if (item.category === 'Marketing' || item.category === 'Content') return 'Screen';
// Social
if (item.category === 'Social') return 'Screen';
// Everything else is a Screen
return 'Screen';
};
// Determine if item is a user-facing touchpoint
const isTouchpoint = (item: WorkItem): boolean => {
const path = item.path.toLowerCase();
const title = item.title.toLowerCase();
// Exclude APIs and backend systems
if (path.startsWith('/api/')) return false;
if (title.includes(' api') || title.includes('api ')) return false;
// Exclude pure auth infrastructure (OAuth endpoints)
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false;
// Include everything else - screens, pages, social posts, blogs, invites, etc.
return true;
};
// Determine if item is technical infrastructure
const isTechnical = (item: WorkItem): boolean => {
const path = item.path.toLowerCase();
const title = item.title.toLowerCase();
// APIs and backend
if (path.startsWith('/api/')) return true;
if (title.includes(' api') || title.includes('api ')) return true;
// Auth infrastructure
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
// System settings
if (item.category === 'Settings' && title.includes('api')) return true;
return false;
};
// Map work items to customer lifecycle journey sections
const getJourneySection = (item: WorkItem): string => {
const title = item.title.toLowerCase();
const path = item.path.toLowerCase();
// Discovery - "I just found you online via social post, blog article, advertisement"
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
if (item.category === 'Content' && (title.includes('blog') || title.includes('article'))) return 'Discovery';
// Research - "Checking out your marketing website - features, price, home page"
if (title.includes('marketing dashboard')) return 'Research';
if (item.category === 'Marketing' && path !== '/') return 'Research';
if (path.includes('/features') || path.includes('/pricing') || path.includes('/about')) return 'Research';
if (item.category === 'Content' && path.includes('/docs') && !title.includes('getting started')) return 'Research';
// Onboarding - "Creating an account to try the product for the first time"
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
if (path.includes('signup') || path.includes('signin') || path.includes('login')) return 'Onboarding';
if (title.includes('authentication') && !title.includes('api')) return 'Onboarding';
// First Use - "Zero state to experiencing the magic solution"
if (title.includes('onboarding')) return 'First Use';
if (title.includes('getting started')) return 'First Use';
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
if (title.includes('creation flow') || title.includes('project creation')) return 'First Use';
if (path.includes('/projects') && path.match(/\/projects\/?$/)) return 'First Use'; // Projects list page
// Active - "I've seen the magic and come back to use it again and again"
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
if (title.includes('plan generation') || title.includes('marketing plan')) return 'Active';
if (path.includes('projects/') && path.length > '/projects/'.length) return 'Active'; // Specific project pages
// Support - "I've got questions, need quick answers to get back to the magic"
if (path.includes('settings')) return 'Support';
if (path.includes('/help') || path.includes('/faq') || path.includes('/support')) return 'Support';
if (item.category === 'Content' && path.includes('/docs') && title.includes('help')) return 'Support';
// Purchase - "Time to pay so I can keep using the magic"
if (path.includes('billing') || path.includes('payment') || path.includes('subscription')) return 'Purchase';
if (path.includes('upgrade') || path.includes('checkout') || path.includes('pricing/buy')) return 'Purchase';
// Default to Active for core product features
return 'Active';
};
const toggleJourneySection = (sectionId: string) => {
setCollapsedJourneySections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
};
// Get emoji icon for journey section
const getJourneySectionIcon = (section: string): string => {
const icons: Record<string, string> = {
'Discovery': '🔍',
'Research': '📚',
'Onboarding': '🎯',
'First Use': '🚀',
'Active': '⚡',
'Support': '💡',
'Purchase': '💳'
};
return icons[section] || '📋';
};
// Get phase status based on overall item status
const getPhaseStatus = (itemStatus: string, phase: 'scope' | 'design' | 'code'): 'built' | 'in_progress' | 'missing' => {
if (itemStatus === 'built') return 'built';
if (itemStatus === 'missing') return 'missing';
// If in_progress, show progression through phases
if (phase === 'scope') return 'built';
if (phase === 'design') return 'in_progress';
return 'missing';
};
// Render status badge
const renderStatusBadge = (status: 'built' | 'in_progress' | 'missing') => {
if (status === 'built') {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs font-medium">
<CheckCircle2 className="h-3 w-3" />
Done
</span>
);
}
if (status === 'in_progress') {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-medium">
<Clock className="h-3 w-3" />
Started
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs font-medium">
<Circle className="h-3 w-3" />
To-do
</span>
);
};
const loadTimelineData = useCallback(async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
const result = await response.json();
// Check if the response is an error
if (result.error) {
console.error('API Error:', result.error, result.details);
alert(`Failed to load timeline: ${result.details || result.error}`);
return;
}
setData(result);
} catch (error) {
console.error('Error loading timeline:', error);
alert('Failed to load timeline data. Check console for details.');
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadTimelineData();
}, [loadTimelineData]);
const regeneratePlan = async () => {
if (!confirm('Regenerate the plan? This will analyze your project and create a fresh MVP checklist.')) {
return;
}
try {
setRegenerating(true);
const response = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to regenerate plan');
}
// Reload the timeline data
await loadTimelineData();
} catch (error) {
console.error('Error regenerating plan:', error);
alert('Failed to regenerate plan. Check console for details.');
} finally {
setRegenerating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!data) {
return <div className="p-8 text-center text-muted-foreground">No timeline data available</div>;
}
return (
<div className="h-full flex flex-col p-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">MVP Checklist</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{data.summary.built} of {data.summary.totalWorkItems} pages built
{data.summary.withActivity} with development activity
</p>
</div>
<div className="flex gap-3 items-center">
{/* View Mode Switcher */}
<div className="flex items-center border rounded-lg p-1">
<Button
variant={viewMode === 'touchpoints' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('touchpoints')}
className="gap-2 h-7"
>
<Eye className="h-4 w-4" />
Touchpoints
</Button>
<Button
variant={viewMode === 'technical' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('technical')}
className="gap-2 h-7"
>
<Cog className="h-4 w-4" />
Technical
</Button>
<Button
variant={viewMode === 'journey' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('journey')}
className="gap-2 h-7"
>
<GitBranch className="h-4 w-4" />
Journey
</Button>
</div>
{/* Regenerate Button */}
<Button
variant="outline"
size="sm"
onClick={regeneratePlan}
disabled={regenerating}
className="gap-2"
>
{regenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Regenerating...
</>
) : (
<>
<RefreshCw className="h-4 w-4" />
Regenerate Plan
</>
)}
</Button>
{/* Summary Stats */}
<div className="text-xs px-3 py-1 bg-green-100 text-green-800 rounded">
{data.summary.built} Built
</div>
<div className="text-xs px-3 py-1 bg-gray-100 text-gray-800 rounded">
{data.summary.missing} To Build
</div>
</div>
</div>
{/* Touchpoints View - What users see and engage with */}
{viewMode === 'touchpoints' && (
<Card className="flex-1 overflow-hidden flex flex-col p-0">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm text-muted-foreground">Everything users see and engage with - screens, features, social posts, blogs, invites, and all customer-facing elements.</p>
</div>
<div className="overflow-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Touchpoint</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.workItems.filter(item => isTouchpoint(item)).map((item, index) => (
<tr
key={item.id}
className="hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
newSet.add(item.id);
}
return newSet;
});
}}
>
<td className="px-4 py-3 text-sm text-muted-foreground">
{getWorkItemType(item)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{item.status === 'built' ? (
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
) : item.status === 'in_progress' ? (
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
) : (
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div>
<div className="text-sm font-medium">{item.title}</div>
{expandedItems.has(item.id) && (
<div className="mt-2 space-y-1">
{item.requirements.map((req) => (
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
{req.status === 'built' ? (
<CheckCircle2 className="h-3 w-3 text-green-600" />
) : (
<Circle className="h-3 w-3 text-gray-400" />
)}
<span>{req.text}</span>
</div>
))}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground">
{item.assigned || data?.projectCreator || 'You'}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.sessionsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.commitsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Journey View - Customer lifecycle stages */}
{viewMode === 'journey' && (
<Card className="flex-1 overflow-auto p-0">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm text-muted-foreground">Customer lifecycle journey from discovery to purchase - organizing all touchpoints and technical components by user stage.</p>
</div>
<div className="divide-y">
{/* Journey Sections - Customer Lifecycle */}
{['Discovery', 'Research', 'Onboarding', 'First Use', 'Active', 'Support', 'Purchase'].map(sectionName => {
const sectionItems = data.workItems.filter(item => getJourneySection(item) === sectionName);
if (sectionItems.length === 0) return null;
const sectionStats = {
done: sectionItems.filter(i => i.status === 'built').length,
started: sectionItems.filter(i => i.status === 'in_progress').length,
todo: sectionItems.filter(i => i.status === 'missing').length,
total: sectionItems.length
};
const isCollapsed = collapsedJourneySections.has(sectionName);
return (
<div key={sectionName}>
{/* Section Header */}
<div
className="bg-muted/30 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors flex items-center justify-between sticky top-0 z-10"
onClick={() => toggleJourneySection(sectionName)}
>
<div className="flex items-center gap-3">
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-lg">{getJourneySectionIcon(sectionName)}</span>
<h3 className="font-semibold text-base">{sectionName}</h3>
<span className="text-xs text-muted-foreground">
{sectionStats.done}/{sectionStats.total} complete
</span>
</div>
<div className="flex gap-2 text-xs">
{sectionStats.done > 0 && (
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
{sectionStats.done} done
</span>
)}
{sectionStats.started > 0 && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
{sectionStats.started} started
</span>
)}
{sectionStats.todo > 0 && (
<span className="px-2 py-1 bg-gray-100 text-gray-800 rounded">
{sectionStats.todo} to-do
</span>
)}
</div>
</div>
{/* Section Items */}
{!isCollapsed && (
<div className="divide-y">
{sectionItems.map(item => (
<div key={item.id} className="px-4 py-3 hover:bg-accent/30 transition-colors">
<div
className="flex items-start justify-between cursor-pointer"
onClick={() => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
newSet.add(item.id);
}
return newSet;
});
}}
>
<div className="flex items-start gap-3 flex-1">
{/* Status Icon */}
{item.status === 'built' ? (
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
) : item.status === 'in_progress' ? (
<Clock className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
) : (
<Circle className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
{/* Title and Type */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{item.title}</span>
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
{getWorkItemType(item)}
</span>
</div>
{/* Phase Status */}
<div className="flex gap-2 mt-2">
<div className="text-xs">
<span className="text-muted-foreground">Spec:</span>{' '}
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
</div>
<div className="text-xs">
<span className="text-muted-foreground">Design:</span>{' '}
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
</div>
<div className="text-xs">
<span className="text-muted-foreground">Code:</span>{' '}
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
</div>
</div>
{/* Expanded Requirements */}
{expandedItems.has(item.id) && (
<div className="mt-3 space-y-1 pl-4 border-l-2 border-gray-200">
<p className="text-xs font-semibold text-muted-foreground mb-2">Requirements:</p>
{item.requirements.map((req) => (
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground">
{req.status === 'built' ? (
<CheckCircle2 className="h-3 w-3 text-green-600" />
) : (
<Circle className="h-3 w-3 text-gray-400" />
)}
<span>{req.text}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Right Side Stats */}
<div className="flex items-start gap-4 text-xs text-muted-foreground">
<div className="text-center">
<div className="font-medium">Sessions</div>
<div className={item.sessionsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.sessionsCount}</div>
</div>
<div className="text-center">
<div className="font-medium">Commits</div>
<div className={item.commitsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.commitsCount}</div>
</div>
<div className="text-center min-w-[60px]">
<div className="font-medium">Cost</div>
<div>{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</Card>
)}
{/* Technical View - Infrastructure that powers everything */}
{viewMode === 'technical' && (
<Card className="flex-1 overflow-hidden flex flex-col p-0">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm text-muted-foreground">Technical infrastructure that powers the product - APIs, backend services, authentication, and system integrations.</p>
</div>
<div className="overflow-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Technical Component</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.workItems.filter(item => isTechnical(item)).map((item, index) => (
<tr
key={item.id}
className="hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
newSet.add(item.id);
}
return newSet;
});
}}
>
<td className="px-4 py-3 text-sm text-muted-foreground">
{getWorkItemType(item)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{item.status === 'built' ? (
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
) : item.status === 'in_progress' ? (
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
) : (
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div>
<div className="text-sm font-medium">{item.title}</div>
{expandedItems.has(item.id) && (
<div className="mt-2 space-y-1">
{item.requirements.map((req) => (
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
{req.status === 'built' ? (
<CheckCircle2 className="h-3 w-3 text-green-600" />
) : (
<Circle className="h-3 w-3 text-gray-400" />
)}
<span>{req.text}</span>
</div>
))}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
</td>
<td className="px-4 py-3">
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground">
{item.assigned || data?.projectCreator || 'You'}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.sessionsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
{item.commitsCount}
</span>
</td>
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,765 +0,0 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useState, useRef, useEffect } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Send, Loader2, Paperclip, X, FileText, RotateCcw, Upload, CheckCircle2, AlertTriangle, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { GitHubRepoPicker } from "@/components/ai/github-repo-picker";
import { PhaseSidebar } from "@/components/ai/phase-sidebar";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
import { ExtractionResultsEditable } from "@/components/ai/extraction-results-editable";
import type { ChatExtractionData } from "@/lib/ai/chat-extraction-types";
import { VisionForm } from "@/components/ai/vision-form";
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
showGitHubPicker?: boolean;
meta?: {
mode?: string;
projectPhase?: string;
artifactsUsed?: string[];
};
}
const MODE_LABELS: Record<string, string> = {
collector_mode: "Collecting context",
extraction_review_mode: "Reviewing signals",
vision_mode: "Product vision",
mvp_mode: "MVP planning",
marketing_mode: "Marketing & launch",
general_chat_mode: "General product chat",
};
type ChatApiResponse = {
reply: string;
mode?: string;
projectPhase?: string;
artifactsUsed?: string[];
};
function ModeBadge({ mode, phase, artifacts }: { mode: string | null; phase: string | null; artifacts?: string[] }) {
if (!mode) return null;
return (
<div className="flex flex-col items-end gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="rounded-full border px-2 py-0.5">
{MODE_LABELS[mode] ?? mode}
</span>
{phase ? <span>{phase}</span> : null}
</div>
{artifacts && artifacts.length > 0 ? (
<span className="text-[10px] text-muted-foreground/80">
Using: {artifacts.join(', ')}
</span>
) : null}
</div>
);
}
export default function GettingStartedPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const { status: sessionStatus } = useSession();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [isSending, setIsSending] = useState(false);
const [attachedFiles, setAttachedFiles] = useState<Array<{name: string, content: string, type: string}>>([]);
const [routerMode, setRouterMode] = useState<string | null>(null);
const [routerPhase, setRouterPhase] = useState<string | null>(null);
const [routerArtifacts, setRouterArtifacts] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showExtractor, setShowExtractor] = useState(false);
const [extractForm, setExtractForm] = useState({
title: "",
provider: "chatgpt",
transcript: "",
sourceLink: "",
});
const [isImporting, setIsImporting] = useState(false);
const [extractionStatus, setExtractionStatus] = useState<"idle" | "importing" | "extracting" | "done" | "error">("idle");
const [extractionError, setExtractionError] = useState<string | null>(null);
const [lastExtraction, setLastExtraction] = useState<ChatExtractionData | null>(null);
const [currentPhase, setCurrentPhase] = useState<string>("collector");
const [hasVisionAnswers, setHasVisionAnswers] = useState<boolean>(false);
const [checkingVision, setCheckingVision] = useState(true);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Load project phase + vision answers from the Postgres-backed API
useEffect(() => {
if (!projectId) return;
const loadProject = async () => {
try {
const res = await fetch(`/api/projects/${projectId}`);
if (res.ok) {
const data = await res.json();
const phase = data.project?.currentPhase || 'collector';
setCurrentPhase(phase);
const hasAnswers = data.project?.visionAnswers?.allAnswered === true;
setHasVisionAnswers(hasAnswers);
}
} catch (error) {
console.error('Error loading project:', error);
} finally {
setCheckingVision(false);
}
};
loadProject();
}, [projectId]);
// Initialize with AI welcome message
useEffect(() => {
if (!isInitialized && projectId && sessionStatus !== 'loading') {
const initialize = async () => {
if (sessionStatus === 'unauthenticated') {
setIsLoading(false);
setIsInitialized(true);
setTimeout(() => sendChatMessage("Hello"), 500);
return;
}
// Signed in via NextAuth — load conversation history
try {
// Fetch existing conversation history
const historyResponse = await fetch(`/api/ai/conversation?projectId=${projectId}`);
let existingMessages: Message[] = [];
if (historyResponse.ok) {
type StoredMessage = {
role: 'user' | 'assistant';
content: string;
createdAt?: string | { _seconds: number };
};
const historyData = await historyResponse.json() as { messages: StoredMessage[] };
existingMessages = historyData.messages
.filter((msg) =>
msg.content !== '[VISION_AGENT_AUTO_START]' &&
msg.content.trim() !== "Hi! I'm here to help." &&
msg.content.trim() !== "Hello" // Filter out auto-generated greeting trigger
)
.map((msg) => ({
id: crypto.randomUUID(),
role: msg.role,
content: msg.content,
timestamp: msg.createdAt
? (typeof msg.createdAt === 'string'
? new Date(msg.createdAt)
: new Date(msg.createdAt._seconds * 1000))
: new Date(),
}));
console.log(`[Chat] Loaded ${existingMessages.length} messages from history`);
}
// If there's existing conversation, just show it
if (existingMessages.length > 0) {
setMessages(existingMessages);
setIsLoading(false);
setIsInitialized(true);
return;
}
// Otherwise, trigger AI to generate the first message
setIsLoading(false);
setIsInitialized(true);
// Automatically send a greeting to get AI's welcome message
setTimeout(() => {
sendChatMessage("Hello");
}, 500);
} catch (error) {
console.error('Error initializing chat:', error);
// Show error state but don't send automatic message
setMessages([{
id: crypto.randomUUID(),
role: 'assistant',
content: "Welcome! There was an issue loading your chat history, but let's get started. What would you like to work on?",
timestamp: new Date(),
}]);
} finally {
setIsLoading(false);
setIsInitialized(true);
}
};
initialize();
}
}, [projectId, isInitialized, sessionStatus]);
const sendChatMessage = async (messageContent: string) => {
const content = messageContent.trim();
if (!content) return;
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setIsSending(true);
try {
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, message: content }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Chat API error: ${response.status} ${errorText}`);
}
const data = (await response.json()) as ChatApiResponse;
const aiMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: data.reply || 'No response generated.',
timestamp: new Date(),
meta: {
mode: data.mode,
projectPhase: data.projectPhase,
artifactsUsed: data.artifactsUsed,
},
};
setMessages((prev) => [...prev, aiMessage]);
setRouterMode(data.mode ?? null);
setRouterPhase(data.projectPhase ?? null);
setRouterArtifacts(data.artifactsUsed ?? []);
} catch (error) {
console.error('Chat send failed', error);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: 'Sorry, something went wrong talking to the AI.',
timestamp: new Date(),
},
]);
} finally {
setIsSending(false);
}
};
const handleSend = () => {
if ((!input.trim() && attachedFiles.length === 0) || isSending) return;
let messageContent = input.trim();
if (attachedFiles.length > 0) {
messageContent += '\n\n**Attached Files:**\n';
attachedFiles.forEach(file => {
messageContent += `\n--- ${file.name} ---\n${file.content}\n`;
});
}
setInput("");
setAttachedFiles([]);
sendChatMessage(messageContent);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleFileUpload = async (files: FileList | null) => {
if (!files) return;
const newFiles: Array<{name: string, content: string, type: string}> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check file size (max 100MB for large exports like ChatGPT conversations)
if (file.size > 100 * 1024 * 1024) {
toast.error(`File ${file.name} is too large (max 100MB)`);
continue;
}
// Read file content
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsText(file);
});
newFiles.push({
name: file.name,
content,
type: file.type,
});
}
setAttachedFiles([...attachedFiles, ...newFiles]);
toast.success(`Added ${newFiles.length} file(s)`);
};
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
e.preventDefault();
const file = item.getAsFile();
if (file) {
await handleFileUpload([file] as unknown as FileList);
}
}
}
};
const removeFile = (index: number) => {
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
};
const handleResetChat = async () => {
if (!confirm('Are you sure you want to reset this conversation? This will delete all messages and start fresh.')) {
return;
}
try {
const response = await fetch(`/api/ai/conversation?projectId=${projectId}`, {
method: 'DELETE',
});
if (response.ok) {
toast.success('Chat reset! Reloading...');
// Reload the page to start fresh
setTimeout(() => window.location.reload(), 500);
} else {
toast.error('Failed to reset chat');
}
} catch (error) {
console.error('Error resetting chat:', error);
toast.error('Failed to reset chat');
}
};
const handleImportAndExtract = async () => {
if (!extractForm.transcript.trim()) {
toast.error("Please paste a transcript first");
return;
}
try {
setIsImporting(true);
setExtractionStatus("importing");
setExtractionError(null);
const importResponse = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: extractForm.title || "Imported AI chat",
provider: extractForm.provider,
transcript: extractForm.transcript,
sourceLink: extractForm.sourceLink || null,
createdAtOriginal: new Date().toISOString(),
}),
});
if (!importResponse.ok) {
const errorData = await importResponse.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to import transcript");
}
const { knowledgeItem } = await importResponse.json();
setExtractionStatus("extracting");
const extractResponse = await fetch(`/api/projects/${projectId}/extract-from-chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ knowledgeItemId: knowledgeItem.id }),
});
if (!extractResponse.ok) {
const errorData = await extractResponse.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to extract signals");
}
const { extraction } = await extractResponse.json();
setLastExtraction(extraction.data);
setExtractionStatus("done");
toast.success("Signals extracted");
} catch (error) {
console.error("[chat extraction] failed", error);
setExtractionStatus("error");
setExtractionError(error instanceof Error ? error.message : "Unknown error");
toast.error("Could not extract signals");
} finally {
setIsImporting(false);
}
};
// Show vision form if no answers yet
if (checkingVision) {
return (
<div className="flex items-center justify-center h-screen">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!hasVisionAnswers) {
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<PhaseSidebar projectId={projectId} />
</CollapsibleSidebar>
{/* Vision Form */}
<div className="flex-1 flex flex-col overflow-auto">
<div className="border-b bg-background/95 backdrop-blur-sm">
<div className="max-w-3xl mx-auto px-4 py-3">
<h2 className="text-lg font-semibold">Let's Start with Your Vision</h2>
<p className="text-xs text-muted-foreground">Answer 3 quick questions to generate your MVP plan</p>
</div>
</div>
<div className="flex-1 overflow-auto">
<VisionForm
projectId={projectId}
workspace={workspace}
onComplete={() => {
setHasVisionAnswers(true);
toast.success('Vision saved! MVP plan generated.');
}}
/>
</div>
</div>
</div>
);
}
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar - Phase-based content */}
<CollapsibleSidebar>
<PhaseSidebar projectId={projectId} />
</CollapsibleSidebar>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Header with Reset Button */}
<div className="border-b bg-background/95 backdrop-blur-sm">
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">AI Assistant</h2>
<p className="text-xs text-muted-foreground">Building your project step-by-step</p>
</div>
<div className="flex flex-col items-end gap-2">
<ModeBadge mode={routerMode} phase={routerPhase} artifacts={routerArtifacts} />
<Button
variant="outline"
size="sm"
onClick={handleResetChat}
disabled={isLoading || isSending}
className="gap-2"
>
<RotateCcw className="h-3.5 w-3.5" />
Reset Chat
</Button>
</div>
</div>
</div>
{/* Messages Container - Scrollable */}
<div className="flex-1 overflow-y-auto pb-[200px]">
<div className="max-w-3xl mx-auto px-4 pt-8 pb-8">
<div className="space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3 animate-in fade-in slide-in-from-bottom-4 duration-500",
message.role === 'user' ? "flex-row-reverse" : ""
)}
>
{/* Avatar */}
{message.role === 'assistant' ? (
<div className="h-8 w-8 rounded-full shrink-0 overflow-hidden bg-white">
<img
src="/vibn-logo-circle.png"
alt="AI"
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground shrink-0">
You
</div>
)}
{/* Message Bubble */}
<div className="flex-1 space-y-2 max-w-[85%]">
<div
className={cn(
"text-[15px] leading-relaxed rounded-2xl px-4 py-3 shadow-sm whitespace-pre-wrap",
message.role === 'assistant'
? "bg-muted/50"
: "bg-primary text-primary-foreground"
)}
>
{message.content}
</div>
{/* GitHub Repo Picker (if AI requested it) */}
{message.role === 'assistant' && message.showGitHubPicker && (
<div className="mt-2">
<GitHubRepoPicker
projectId={projectId}
onRepoSelected={(repo) => {
const confirmMessage = `Yes, I connected ${repo.full_name}`;
sendChatMessage(confirmMessage);
}}
/>
</div>
)}
<p className={cn(
"text-xs text-muted-foreground px-2",
message.role === 'user' ? "text-right" : ""
)}>
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))}
{isSending && (
<div className="flex gap-3 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="h-8 w-8 rounded-full shrink-0 overflow-hidden bg-white">
<img
src="/vibn-logo-circle.png"
alt="AI thinking"
className="h-full w-full object-cover"
/>
</div>
<div className="flex-1 space-y-1">
<div className="text-sm bg-muted rounded-2xl px-5 py-3 w-fit">
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs font-medium">Assistant is thinking…</span>
</div>
</div>
</div>
</div>
)}
{/* Show Extraction Results at bottom if in extraction_review phase */}
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
<div className="mt-8">
<ExtractionResultsEditable projectId={projectId} />
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Floating Chat Input - Fixed at Bottom */}
<div className="absolute bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur-sm shadow-lg z-10">
<div className="max-w-3xl mx-auto px-4 py-3 space-y-3">
{false && showExtractor && (
<Card className="border-primary/30 bg-primary/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Paste AI chat transcript → extract signals
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground uppercase">Title</label>
<Input
placeholder="ChatGPT brainstorm"
value={extractForm.title}
onChange={(e) => setExtractForm((prev) => ({ ...prev, title: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground uppercase">Provider</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={extractForm.provider}
onChange={(e) => setExtractForm((prev) => ({ ...prev, provider: e.target.value }))}
>
{['chatgpt', 'gemini', 'claude', 'cursor', 'vibn', 'other'].map((provider) => (
<option key={provider} value={provider}>
{provider.toUpperCase()}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5 text-sm">
<label className="text-xs text-muted-foreground uppercase">Transcript</label>
<Textarea
placeholder="Paste the AI conversation here..."
className="min-h-[120px]"
value={extractForm.transcript}
onChange={(e) => setExtractForm((prev) => ({ ...prev, transcript: e.target.value }))}
/>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<Button onClick={handleImportAndExtract} disabled={isImporting}>
{isImporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Processing
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Import & Extract
</>
)}
</Button>
{extractionStatus === "done" && lastExtraction && (
<span className="text-emerald-600 text-xs flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" />
Signals captured below
</span>
)}
{extractionStatus === "error" && extractionError && (
<span className="text-destructive text-xs flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
{extractionError}
</span>
)}
</div>
{lastExtraction && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="rounded-lg border bg-background p-3">
<p className="text-xs text-muted-foreground uppercase">Working title</p>
<p className="font-medium">
{lastExtraction?.project_summary?.working_title || "Not captured"}
</p>
<p className="text-xs text-muted-foreground uppercase mt-2">One-liner</p>
<p>{lastExtraction?.project_summary?.one_liner || "—"}</p>
</div>
<div className="rounded-lg border bg-background p-3">
<p className="text-xs text-muted-foreground uppercase">Primary problem</p>
<p>{lastExtraction?.product_vision?.problem_statement?.description || "Not detected"}</p>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Attached Files Display */}
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 bg-muted px-3 py-2 rounded-lg text-sm border"
>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{file.name}</span>
<button
onClick={() => removeFile(index)}
className="ml-1 hover:text-destructive"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
<div className="flex gap-2 items-end">
<div className="flex-1">
<Textarea
placeholder="Message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="min-h-[48px] max-h-[120px] resize-none bg-background shadow-sm text-[15px]"
disabled={isLoading || isSending}
/>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.py,.java,.cpp,.c,.html,.css,.xml,.yaml,.yml"
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
{/* File Upload Button */}
<Button
size="icon"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading || isSending}
className="h-[48px] w-[48px] shrink-0"
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
{/* Send Button */}
<Button
size="icon"
onClick={handleSend}
disabled={(!input.trim() && attachedFiles.length === 0) || isLoading || isSending}
className="h-[48px] w-[48px] shrink-0"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1.5 px-1">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-[10px] border">Enter</kbd> to send <kbd className="px-1 py-0.5 bg-muted rounded text-[10px] border">Shift+Enter</kbd> for new line
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,8 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
import { ReactNode, useState } from "react";
import { ReactNode } from "react";
import { useParams } from "next/navigation";
import { Toaster } from "sonner";
@@ -14,26 +13,16 @@ export default function ProjectsLayout({
}) {
const params = useParams();
const workspace = params.workspace as string;
const [activeSection, setActiveSection] = useState<string>("projects");
return (
<>
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
<VIBNSidebar workspace={workspace} />
<main style={{ flex: 1, overflow: "auto" }}>
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
{/* Project Association Prompt - Detects new workspaces */}
<ProjectAssociationPrompt workspace={workspace} />
<Toaster position="top-center" />
</>
);

View File

@@ -1,8 +0,0 @@
export default function NewProjectLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -1,462 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowLeft, ArrowRight, Check, Sparkles, Code2 } from "lucide-react";
import { useRouter } from "next/navigation";
type ProjectType = "scratch" | "existing" | null;
export default function NewProjectPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [projectName, setProjectName] = useState("");
const [projectType, setProjectType] = useState<ProjectType>(null);
// Product vision (can skip)
const [productVision, setProductVision] = useState("");
// Product details
const [productName, setProductName] = useState("");
const [isForClient, setIsForClient] = useState<boolean | null>(null);
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
const [hasDomain, setHasDomain] = useState<boolean | null>(null);
const [hasWebsite, setHasWebsite] = useState<boolean | null>(null);
const [hasGithub, setHasGithub] = useState<boolean | null>(null);
const [hasChatGPT, setHasChatGPT] = useState<boolean | null>(null);
const [isCheckingSlug, setIsCheckingSlug] = useState(false);
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const checkSlugAvailability = async (name: string) => {
const slug = generateSlug(name);
if (!slug) return;
setIsCheckingSlug(true);
// TODO: Replace with actual API call
await new Promise(resolve => setTimeout(resolve, 500));
// Mock check - in reality, check against database
const isAvailable = !["test", "demo", "admin"].includes(slug);
setSlugAvailable(isAvailable);
setIsCheckingSlug(false);
};
const handleProductNameChange = (value: string) => {
setProductName(value);
setSlugAvailable(null);
if (value.length > 2) {
checkSlugAvailability(value);
}
};
const handleNext = () => {
if (step === 1 && projectName && projectType) {
setStep(2);
} else if (step === 2) {
// Can skip questions
setStep(3);
} else if (step === 3 && productName && slugAvailable) {
handleCreateProject();
}
};
const handleBack = () => {
if (step > 1) setStep(step - 1);
};
const handleSkipQuestions = () => {
setStep(3);
};
const handleCreateProject = async () => {
const slug = generateSlug(productName);
const projectData = {
projectName,
projectType,
slug,
vision: productVision,
product: {
name: productName,
isForClient,
hasLogo,
hasDomain,
hasWebsite,
hasGithub,
hasChatGPT,
},
};
// TODO: API call to create project
console.log("Creating project:", projectData);
// Redirect to the new project
router.push(`/${slug}/overview`);
};
const canProceedStep1 = projectName.trim() && projectType;
const canProceedStep3 = productName.trim() && slugAvailable;
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-2xl">
{/* Header */}
<div className="mb-8">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/projects")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Projects
</Button>
<h1 className="text-3xl font-bold">Create New Project</h1>
<p className="text-muted-foreground mt-2">
Step {step} of 3
</p>
</div>
{/* Progress */}
<div className="flex gap-2 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-2 flex-1 rounded-full transition-colors ${
s <= step ? "bg-primary" : "bg-muted"
}`}
/>
))}
</div>
{/* Step 1: Project Setup */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Project Setup</CardTitle>
<CardDescription>
Give your project a name and choose how you want to start
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="projectName">Project Name</Label>
<Input
id="projectName"
placeholder="My Awesome Project"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
/>
</div>
<div className="space-y-3">
<Label>Starting Point</Label>
<div className="grid gap-3">
<button
onClick={() => setProjectType("scratch")}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
projectType === "scratch"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 mt-0.5 text-primary" />
<div>
<div className="font-medium">Start from scratch</div>
<div className="text-sm text-muted-foreground">
Build a new project with AI assistance
</div>
</div>
{projectType === "scratch" && (
<Check className="h-5 w-5 ml-auto text-primary" />
)}
</div>
</button>
<button
onClick={() => setProjectType("existing")}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
projectType === "existing"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-start gap-3">
<Code2 className="h-5 w-5 mt-0.5 text-primary" />
<div>
<div className="font-medium">Existing project</div>
<div className="text-sm text-muted-foreground">
Import and enhance an existing codebase
</div>
</div>
{projectType === "existing" && (
<Check className="h-5 w-5 ml-auto text-primary" />
)}
</div>
</button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Product Vision */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle>Describe your product vision</CardTitle>
<CardDescription>
Help us understand your project (you can skip this)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Textarea
placeholder="Describe who you're building for, what problem they have, and how you plan to solve it..."
value={productVision}
onChange={(e) => setProductVision(e.target.value)}
rows={8}
className="resize-none"
/>
</div>
<Button
variant="ghost"
className="w-full"
onClick={handleSkipQuestions}
>
Skip this step
</Button>
</CardContent>
</Card>
)}
{/* Step 3: Product Details */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle>Product Details</CardTitle>
<CardDescription>
Tell us about your product
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="productName">Product Name *</Label>
<Input
id="productName"
placeholder="Taskify"
value={productName}
onChange={(e) => handleProductNameChange(e.target.value)}
/>
{productName && (
<div className="text-xs text-muted-foreground">
{isCheckingSlug ? (
<span>Checking availability...</span>
) : slugAvailable === true ? (
<span className="text-green-600">
URL available: vibn.app/{generateSlug(productName)}
</span>
) : slugAvailable === false ? (
<span className="text-red-600">
This name is already taken
</span>
) : null}
</div>
)}
</div>
<div className="space-y-4">
{/* Client or Self */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Is this for a client or yourself?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={isForClient === true ? "default" : "outline"}
onClick={() => setIsForClient(true)}
size="sm"
className="w-20 h-8"
>
Client
</Button>
<Button
type="button"
variant={isForClient === false ? "default" : "outline"}
onClick={() => setIsForClient(false)}
size="sm"
className="w-20 h-8"
>
Myself
</Button>
</div>
</div>
{/* Logo */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Does it have a logo?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasLogo === true ? "default" : "outline"}
onClick={() => setHasLogo(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasLogo === false ? "default" : "outline"}
onClick={() => setHasLogo(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* Domain */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Does it have a domain?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasDomain === true ? "default" : "outline"}
onClick={() => setHasDomain(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasDomain === false ? "default" : "outline"}
onClick={() => setHasDomain(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* Website */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Does it have a website?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasWebsite === true ? "default" : "outline"}
onClick={() => setHasWebsite(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasWebsite === false ? "default" : "outline"}
onClick={() => setHasWebsite(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* GitHub */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Do you have a GitHub repository?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasGithub === true ? "default" : "outline"}
onClick={() => setHasGithub(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasGithub === false ? "default" : "outline"}
onClick={() => setHasGithub(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
{/* ChatGPT */}
<div className="flex items-center justify-between">
<Label className="text-sm font-normal">Do you have your ideas in a ChatGPT project?</Label>
<div className="flex gap-2">
<Button
type="button"
variant={hasChatGPT === true ? "default" : "outline"}
onClick={() => setHasChatGPT(true)}
size="sm"
className="w-16 h-8"
>
Yes
</Button>
<Button
type="button"
variant={hasChatGPT === false ? "default" : "outline"}
onClick={() => setHasChatGPT(false)}
size="sm"
className="w-16 h-8"
>
No
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex gap-3 mt-6">
{step > 1 && (
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
<Button
className="ml-auto"
onClick={handleNext}
disabled={
(step === 1 && !canProceedStep1) ||
(step === 3 && !canProceedStep3) ||
isCheckingSlug
}
>
{step === 3 ? "Create Project" : "Next"}
{step < 3 && <ArrowRight className="h-4 w-4 ml-2" />}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,38 +2,9 @@
import { useEffect, useState } from "react";
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 {
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 Link from "next/link";
import { ProjectCreationModal } from "@/components/project-creation-modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
@@ -44,34 +15,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Loader2, Trash2 } from "lucide-react";
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;
slug: string;
productName: string;
productVision?: string;
workspacePath?: string;
status?: string;
createdAt: string | null;
updatedAt: string | null;
giteaRepo?: string;
giteaRepoUrl?: string;
theiaWorkspaceUrl?: string;
contextSnapshot?: ContextSnapshot;
stats: {
sessions: number;
costs: number;
};
stats: { sessions: number; costs: number };
}
function timeAgo(dateStr?: string | null): string {
@@ -89,19 +42,27 @@ function timeAgo(dateStr?: string | null): string {
return `${Math.floor(days / 30)}mo ago`;
}
function DeployDot({ status }: { status?: string }) {
if (!status) return null;
const map: Record<string, string> = {
finished: "bg-green-500",
in_progress: "bg-blue-500 animate-pulse",
queued: "bg-yellow-400",
failed: "bg-red-500",
};
function StatusDot({ status }: { status?: string }) {
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#d4a04a";
const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none";
return (
<span
className={`inline-block h-2 w-2 rounded-full ${map[status] ?? "bg-gray-400"}`}
title={status}
/>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0, animation: anim }} />
);
}
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: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
<StatusDot status={status} /> {label}
</span>
);
}
@@ -112,38 +73,20 @@ export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreationModal, setShowCreationModal] = useState(false);
const [showNew, setShowNew] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const fetchProjects = async () => {
try {
setLoading(true);
const res = await fetch("/api/projects");
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to fetch projects");
}
if (!res.ok) throw new Error("Failed to fetch projects");
const data = await res.json();
const loaded: ProjectWithStats[] = data.projects || [];
setProjects(loaded);
setError(null);
// Fire-and-forget: prewarm all provisioned IDE workspaces so containers
// are already running by the time the user clicks "Open IDE"
const warmUrls = loaded
.map((p) => p.theiaWorkspaceUrl)
.filter((u): u is string => Boolean(u));
if (warmUrls.length > 0) {
fetch("/api/projects/prewarm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls: warmUrls }),
}).catch(() => {}); // ignore errors — this is best-effort
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
setProjects(data.projects ?? []);
} catch {
/* silent */
} finally {
setLoading(false);
}
@@ -154,7 +97,7 @@ export default function ProjectsPage() {
else if (status === "unauthenticated") setLoading(false);
}, [status]);
const handleDeleteProject = async () => {
const handleDelete = async () => {
if (!projectToDelete) return;
setIsDeleting(true);
try {
@@ -178,204 +121,203 @@ export default function ProjectsPage() {
}
};
if (status === "loading") {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
const statusSummary = () => {
const live = projects.filter((p) => p.status === "live").length;
const building = projects.filter((p) => p.status === "building").length;
const defining = projects.filter((p) => !p.status || p.status === "defining").length;
const parts = [];
if (defining) parts.push(`${defining} defining`);
if (building) parts.push(`${building} building`);
if (live) parts.push(`${live} live`);
return `${projects.length} total · ${parts.join(" · ")}`;
};
return (
<>
<div className="container mx-auto py-8 px-4 max-w-6xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold">Projects</h1>
<p className="text-muted-foreground text-sm mt-1">{session?.user?.email}</p>
</div>
<Button onClick={() => setShowCreationModal(true)}>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
<div
className="vibn-enter"
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
<div>
<h1 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
lineHeight: 1.15, marginBottom: 4,
}}>
Projects
</h1>
{!loading && (
<p style={{ fontSize: "0.82rem", color: "#a09a90" }}>{statusSummary()}</p>
)}
</div>
{/* States */}
{loading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="py-6">
<p className="text-sm text-red-600">Error: {error}</p>
</CardContent>
</Card>
)}
{/* Projects Grid */}
{!loading && !error && projects.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const href = `/${workspace}/project/${project.id}/overview`;
const snap = project.contextSnapshot;
const deployStatus = snap?.lastDeployment?.status;
return (
<div key={project.id} className="relative group">
<Link href={href}>
<Card className="hover:border-primary/50 hover:shadow-sm transition-all cursor-pointer h-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">{project.productName}</CardTitle>
<CardDescription className="text-xs mt-0.5">
{timeAgo(project.updatedAt)}
</CardDescription>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge
variant={project.status === "active" ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0"
>
{project.status ?? "active"}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e: React.MouseEvent) => e.preventDefault()}>
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
setProjectToDelete(project);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Vision */}
{project.productVision && (
<p className="text-xs text-muted-foreground line-clamp-2">
{project.productVision}
</p>
)}
{/* Gitea repo + last commit */}
{project.giteaRepo && (
<div className="rounded-md border bg-muted/20 p-2 space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<GitBranch className="h-3 w-3" />
<span className="font-mono truncate">{project.giteaRepo}</span>
{snap?.currentBranch && (
<span className="text-[10px] px-1.5 py-0 bg-muted rounded-full shrink-0">
{snap.currentBranch}
</span>
)}
</div>
{snap?.lastCommit ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<GitCommit className="h-3 w-3 shrink-0" />
<span className="font-mono text-[10px]">{snap.lastCommit.sha.slice(0, 7)}</span>
<span className="truncate flex-1">{snap.lastCommit.message}</span>
<span className="shrink-0">{timeAgo(snap.lastCommit.timestamp)}</span>
</div>
) : (
<p className="text-[10px] text-muted-foreground">No commits yet</p>
)}
</div>
)}
{/* Footer row: deploy + stats + IDE */}
<div className="flex items-center justify-between pt-1 border-t">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{deployStatus && (
<span className="flex items-center gap-1">
<DeployDot status={deployStatus} />
{deployStatus === "finished" ? "Live" : deployStatus}
</span>
)}
<span>{project.stats.sessions} sessions</span>
<span>${project.stats.costs.toFixed(2)}</span>
</div>
{project.theiaWorkspaceUrl && (
<a
href={project.theiaWorkspaceUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-[10px] text-primary hover:underline"
>
<Terminal className="h-3 w-3" />
IDE
</a>
)}
</div>
</CardContent>
</Card>
</Link>
</div>
);
})}
{/* Create card */}
<Card
className="hover:border-primary/50 transition-all cursor-pointer border-dashed"
onClick={() => setShowCreationModal(true)}
>
<CardContent className="flex flex-col items-center justify-center h-full min-h-[220px] p-6">
<div className="rounded-full bg-muted p-4 mb-3">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold mb-1 text-sm">New Project</h3>
<p className="text-xs text-muted-foreground text-center">
Auto-provisions a Gitea repo and workspace
</p>
</CardContent>
</Card>
</div>
)}
{/* Empty state */}
{!loading && !error && projects.length === 0 && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="rounded-full bg-muted p-6 mb-4">
<Sparkles className="h-12 w-12 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No projects yet</h3>
<p className="text-sm text-muted-foreground text-center max-w-md mb-6">
Create your first project. Vibn will automatically provision a Gitea repo,
register webhooks, and prepare your IDE workspace.
</p>
<Button size="lg" onClick={() => setShowCreationModal(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Your First Project
</Button>
</CardContent>
</Card>
)}
<button
onClick={() => setShowNew(true)}
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "8px 16px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
border: "1px solid #1a1a1a",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
>
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
New project
</button>
</div>
{/* Loading */}
{loading && (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 64 }}>
<Loader2 style={{ width: 28, height: 28, color: "#b5b0a6" }} className="animate-spin" />
</div>
)}
{/* Project list */}
{!loading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{projects.map((p, i) => (
<div
key={p.id}
className="vibn-enter"
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
>
<Link
href={`/${workspace}/project/${p.id}/overview`}
style={{
width: "100%", display: "flex", alignItems: "center",
padding: "18px 22px", borderRadius: 10,
background: "#fff", border: "1px solid #e8e4dc",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
textDecoration: "none", boxShadow: "0 1px 2px #1a1a1a05",
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
setHoveredId(p.id);
e.currentTarget.style.borderColor = "#d0ccc4";
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
}}
onMouseLeave={(e) => {
setHoveredId(null);
e.currentTarget.style.borderColor = "#e8e4dc";
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
}}
>
{/* Project initial */}
<div style={{
width: 36, height: 36, borderRadius: 9, marginRight: 16,
background: "#1a1a1a12",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
}}>
<span style={{
fontFamily: "var(--font-lora), ui-serif, serif",
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
}}>
{p.productName[0]?.toUpperCase() ?? "P"}
</span>
</div>
{/* Name + vision */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "#1a1a1a" }}>
{p.productName}
</span>
<StatusTag status={p.status} />
</div>
{p.productVision && (
<span style={{ fontSize: "0.78rem", color: "#a09a90", display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.productVision}
</span>
)}
</div>
{/* Meta */}
<div style={{ display: "flex", gap: 28, alignItems: "center", flexShrink: 0 }}>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>
Last active
</div>
<div style={{ fontSize: "0.78rem", color: "#6b6560" }}>{timeAgo(p.updatedAt)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>
Sessions
</div>
<div style={{ fontSize: "0.78rem", color: "#6b6560" }}>{p.stats.sessions}</div>
</div>
</div>
{/* Delete (visible on row hover) */}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
style={{
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
border: "none", background: "transparent",
color: "#c0bab2", cursor: "pointer",
opacity: hoveredId === p.id ? 1 : 0,
transition: "opacity 0.15s, color 0.15s",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
title="Delete project"
>
<Trash2 style={{ width: 14, height: 14 }} />
</button>
</Link>
</div>
))}
{/* New project card */}
<button
onClick={() => setShowNew(true)}
style={{
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
padding: "22px", borderRadius: 10,
background: "transparent", border: "1px dashed #d0ccc4",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#b5b0a6", fontSize: "0.84rem", fontWeight: 500,
transition: "all 0.15s",
animationDelay: `${projects.length * 0.05}s`,
}}
className="vibn-enter"
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "#8a8478"; e.currentTarget.style.color = "#6b6560"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "#d0ccc4"; e.currentTarget.style.color = "#b5b0a6"; }}
>
+ New project
</button>
</div>
)}
{/* Empty state */}
{!loading && projects.length === 0 && (
<div style={{ textAlign: "center", paddingTop: 64 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
No projects yet
</h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 24 }}>
Tell Vibn what you want to build and it will figure out the rest.
</p>
<button
onClick={() => setShowNew(true)}
style={{
padding: "10px 22px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
border: "none", fontSize: "0.84rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
>
Create your first project
</button>
</div>
)}
<ProjectCreationModal
open={showCreationModal}
onOpenChange={(open) => {
setShowCreationModal(open);
if (!open) fetchProjects();
}}
open={showNew}
onOpenChange={(open) => { setShowNew(open); if (!open) fetchProjects(); }}
workspace={workspace}
/>
@@ -391,20 +333,16 @@ export default function ProjectsPage() {
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProject}
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
Delete Project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
</div>
);
}

View File

@@ -1,8 +1,8 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { ReactNode } from "react";
import { useParams } from "next/navigation";
import { Toaster } from "sonner";
export default function SettingsLayout({
@@ -10,25 +10,18 @@ export default function SettingsLayout({
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("settings");
const params = useParams();
const workspace = params.workspace as string;
return (
<>
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
<VIBNSidebar workspace={workspace} />
<main style={{ flex: 1, overflow: "auto" }}>
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
<Toaster position="top-center" />
</>
);
}

View File

@@ -1,24 +0,0 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
export default function TestApiKeyLayout({
children,
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("connections");
return (
<div className="flex h-screen w-full overflow-hidden bg-background">
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
<RightPanel />
</div>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { auth } from "@/lib/firebase/config";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export default function TestApiKeyPage() {
const [results, setResults] = useState<any>(null);
const [loading, setLoading] = useState(false);
const testApiKey = async () => {
setLoading(true);
try {
const user = auth.currentUser;
if (!user) {
setResults({ error: "Not authenticated. Please sign in first." });
return;
}
const token = await user.getIdToken();
console.log('[Test] Calling /api/user/api-key...');
console.log('[Test] Token length:', token.length);
const response = await fetch('/api/user/api-key', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
console.log('[Test] Response status:', response.status);
console.log('[Test] Response headers:', Object.fromEntries(response.headers.entries()));
const text = await response.text();
console.log('[Test] Response text:', text);
let data;
try {
data = JSON.parse(text);
} catch (e) {
data = { rawResponse: text };
}
setResults({
status: response.status,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
data: data,
userInfo: {
uid: user.uid,
email: user.email,
}
});
} catch (error: any) {
console.error('[Test] Error:', error);
setResults({ error: error.message, stack: error.stack });
} finally {
setLoading(false);
}
};
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
testApiKey();
}
});
return () => unsubscribe();
}, []);
return (
<div className="flex h-full flex-col overflow-auto p-8">
<div className="max-w-4xl space-y-6">
<div>
<h1 className="text-4xl font-bold mb-2">API Key Test</h1>
<p className="text-muted-foreground">Testing /api/user/api-key endpoint</p>
</div>
<Card>
<CardHeader>
<CardTitle>Test Results</CardTitle>
</CardHeader>
<CardContent>
{loading && <p>Testing API key endpoint...</p>}
{results && (
<pre className="bg-muted p-4 rounded-lg overflow-auto text-xs">
{JSON.stringify(results, null, 2)}
</pre>
)}
</CardContent>
</Card>
<Button onClick={testApiKey} disabled={loading}>
{loading ? "Testing..." : "Test Again"}
</Button>
</div>
</div>
);
}

View File

@@ -1,29 +0,0 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
export default function TestAuthLayout({
children,
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("connections");
return (
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
);
}

View File

@@ -1,99 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { auth } from "@/lib/firebase/config";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export default function TestAuthPage() {
const [results, setResults] = useState<any>(null);
const [loading, setLoading] = useState(false);
const runDiagnostics = async () => {
setLoading(true);
try {
const user = auth.currentUser;
if (!user) {
setResults({ error: "Not authenticated. Please sign in first." });
return;
}
const token = await user.getIdToken();
// Test with token
const response = await fetch('/api/diagnose', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setResults({
...data,
clientInfo: {
uid: user.uid,
email: user.email,
tokenLength: token.length,
}
});
} catch (error: any) {
setResults({ error: error.message });
} finally {
setLoading(false);
}
};
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
console.log('[Test Auth] Auth state changed:', user ? user.uid : 'No user');
if (user) {
runDiagnostics();
} else {
setResults({
error: "Not authenticated. Please sign in first.",
note: "Redirecting to auth page...",
});
// Redirect to auth page after a delay
setTimeout(() => {
window.location.href = '/auth';
}, 2000);
}
});
return () => unsubscribe();
}, []);
return (
<div className="flex h-full flex-col overflow-auto p-8">
<div className="max-w-4xl space-y-6">
<div>
<h1 className="text-4xl font-bold mb-2">Auth Diagnostics</h1>
<p className="text-muted-foreground">Testing Firebase authentication and token verification</p>
</div>
<Card>
<CardHeader>
<CardTitle>Diagnostic Results</CardTitle>
</CardHeader>
<CardContent>
{loading && <p>Running diagnostics...</p>}
{results && (
<pre className="bg-muted p-4 rounded-lg overflow-auto text-xs">
{JSON.stringify(results, null, 2)}
</pre>
)}
{!loading && !results && (
<p className="text-muted-foreground">Click "Run Diagnostics" to test</p>
)}
</CardContent>
</Card>
<Button onClick={runDiagnostics} disabled={loading}>
{loading ? "Running..." : "Run Diagnostics Again"}
</Button>
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
export default function TestSessionsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -1,119 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { db, auth } from '@/lib/firebase/config';
import { collection, query, where, orderBy, limit, getDocs } from 'firebase/firestore';
export default function TestSessionsPage() {
const [sessions, setSessions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged(async (user) => {
if (!user) {
setError('Not authenticated');
setLoading(false);
return;
}
try {
const sessionsRef = collection(db, 'sessions');
const q = query(
sessionsRef,
where('userId', '==', user.uid),
orderBy('createdAt', 'desc'),
limit(20)
);
const snapshot = await getDocs(q);
const sessionData = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setSessions(sessionData);
} catch (err: any) {
console.error('Error fetching sessions:', err);
setError(err.message);
} finally {
setLoading(false);
}
});
return () => unsubscribe();
}, []);
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Recent Sessions</h1>
{loading && <p>Loading...</p>}
{error && <p className="text-red-500">Error: {error}</p>}
{!loading && sessions.length === 0 && (
<p className="text-gray-500">No sessions found yet. Make sure you&apos;re coding in Cursor with the extension enabled!</p>
)}
{sessions.length > 0 && (
<div className="space-y-4">
{sessions.map((session) => (
<div key={session.id} className="p-4 border rounded-lg bg-card">
<div className="grid grid-cols-2 gap-2 text-sm">
<div><strong>Session ID:</strong> {session.id}</div>
<div><strong>User ID:</strong> {session.userId?.substring(0, 20)}...</div>
<div className="col-span-2 mt-2">
<strong>🗂 Workspace:</strong>
<div className="font-mono text-xs bg-muted p-2 rounded mt-1">
{session.workspacePath || 'N/A'}
</div>
{session.workspaceName && (
<div className="text-muted-foreground mt-1">
Project: <span className="font-medium">{session.workspaceName}</span>
</div>
)}
</div>
<div><strong>Created:</strong> {session.createdAt?.toDate?.()?.toLocaleString() || 'N/A'}</div>
<div><strong>Duration:</strong> {session.duration ? `${session.duration}s` : 'N/A'}</div>
<div><strong>Model:</strong> {session.model || 'unknown'}</div>
<div><strong>Cost:</strong> ${session.cost?.toFixed(4) || '0.0000'}</div>
<div><strong>Tokens Used:</strong> {session.tokensUsed || 0}</div>
<div><strong>Files Modified:</strong> {session.filesModified?.length || 0}</div>
</div>
{session.filesModified && session.filesModified.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-primary hover:underline text-sm">
View Modified Files ({session.filesModified.length})
</summary>
<div className="mt-2 p-2 bg-muted rounded text-xs space-y-1">
{session.filesModified.map((file: string, idx: number) => (
<div key={idx} className="font-mono">{file}</div>
))}
</div>
</details>
)}
{session.conversationSummary && (
<details className="mt-3">
<summary className="cursor-pointer text-primary hover:underline text-sm">
View Conversation Summary
</summary>
<div className="mt-2 p-3 bg-muted rounded text-sm">
{session.conversationSummary}
</div>
</details>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
"use client";
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
import { RightPanel } from "@/components/layout/right-panel";
import { ReactNode, useState } from "react";
import { Toaster } from "sonner";
export default function UsersLayout({
children,
}: {
children: ReactNode;
}) {
const [activeSection, setActiveSection] = useState<string>("users");
return (
<>
<div className="flex h-screen w-full overflow-hidden bg-background">
{/* Left Rail - Workspace Navigation */}
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
{/* Right Panel - AI Chat */}
<RightPanel />
</div>
<Toaster position="top-center" />
</>
);
}

View File

@@ -1,190 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { auth } from '@/lib/firebase/config';
import { toast } from 'sonner';
import { Users, UserPlus, Crown, Mail } from 'lucide-react';
import { useParams } from 'next/navigation';
interface WorkspaceUser {
id: string;
email: string;
displayName: string;
role: 'owner' | 'admin' | 'member';
joinedAt: any;
lastActive: any;
}
export default function UsersPage() {
const params = useParams();
const workspace = params.workspace as string;
const [users, setUsers] = useState<WorkspaceUser[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<WorkspaceUser | null>(null);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const user = auth.currentUser;
if (!user) return;
const token = await user.getIdToken();
const response = await fetch(`/api/workspace/${workspace}/users`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setUsers(data.users);
setCurrentUser(data.currentUser);
}
} catch (error) {
console.error('Error loading users:', error);
toast.error('Failed to load workspace users');
} finally {
setLoading(false);
}
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'owner':
return 'bg-purple-500/10 text-purple-600 border-purple-500/20';
case 'admin':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
default:
return 'bg-gray-500/10 text-gray-600 border-gray-500/20';
}
};
return (
<div className="flex h-full flex-col overflow-auto">
<div className="flex-1 p-8 space-y-8 max-w-6xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold mb-2">Team Members</h1>
<p className="text-muted-foreground text-lg">
Manage workspace access and team collaboration
</p>
</div>
<Button disabled>
<UserPlus className="mr-2 h-4 w-4" />
Invite User
</Button>
</div>
{/* Current User Info */}
{currentUser && (
<Card className="border-primary/50 bg-primary/5">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Crown className="h-4 w-4" />
Your Account
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{currentUser.displayName || 'Unknown'}</p>
<p className="text-sm text-muted-foreground">{currentUser.email}</p>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-medium border ${getRoleBadgeColor(currentUser.role)}`}>
{currentUser.role}
</div>
</div>
</CardContent>
</Card>
)}
{/* Users List */}
{loading ? (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Loading team members...</p>
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center space-y-4">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Users className="h-8 w-8 text-muted-foreground" />
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">No team members yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Invite team members to collaborate on projects in this workspace
</p>
<Button disabled>
<UserPlus className="mr-2 h-4 w-4" />
Invite Your First Team Member
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{users.map((user) => (
<Card key={user.id}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<Mail className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{user.displayName || 'Unknown'}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
{user.lastActive && (
<p className="text-xs text-muted-foreground mt-1">
Last active: {new Date(user.lastActive._seconds * 1000).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className={`px-3 py-1 rounded-full text-xs font-medium border ${getRoleBadgeColor(user.role)}`}>
{user.role}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Info Card */}
<Card className="border-blue-500/20 bg-blue-500/5">
<CardHeader>
<CardTitle className="text-base">Team Collaboration (Coming Soon)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<strong>👥 Team Workspaces:</strong> Invite team members to collaborate on projects together.
</p>
<p>
<strong>🔐 Role-Based Access:</strong> Control what team members can see and do with flexible permissions.
</p>
<p>
<strong>💬 Shared Context:</strong> All team members can access shared AI chat history and project documentation.
</p>
<p className="text-xs italic pt-2">
This feature is currently in development. Check back soon!
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
/**
* POST /api/admin/migrate
*
* One-shot migration endpoint. Requires the ADMIN_MIGRATE_SECRET env var
* to be set and passed as x-admin-secret header (or ?secret= query param).
*
* Idempotent — safe to call multiple times (all statements use IF NOT EXISTS).
*
* curl -X POST https://vibnai.com/api/admin/migrate \
* -H "x-admin-secret: <ADMIN_MIGRATE_SECRET>"
*/
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { readFileSync } from "fs";
import { join } from "path";
export async function POST(req: NextRequest) {
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
if (!secret) {
return NextResponse.json(
{ error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" },
{ status: 403 }
);
}
const incoming =
req.headers.get("x-admin-secret") ??
new URL(req.url).searchParams.get("secret") ??
"";
if (incoming !== secret) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const results: Array<{ statement: string; ok: boolean; error?: string }> = [];
// Inline the DDL so this works even if the SQL file isn't on the runtime fs
const statements = [
`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`,
`CREATE TABLE IF NOT EXISTS fs_users (
id TEXT PRIMARY KEY,
user_id TEXT,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS fs_users_email_idx ON fs_users ((data->>'email'))`,
`CREATE INDEX IF NOT EXISTS fs_users_user_id_idx ON fs_users (user_id)`,
`CREATE TABLE IF NOT EXISTS fs_projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS fs_projects_user_idx ON fs_projects (user_id)`,
`CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx ON fs_projects (workspace)`,
`CREATE TABLE IF NOT EXISTS fs_sessions (
id TEXT PRIMARY KEY,
user_id TEXT,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS fs_sessions_user_idx ON fs_sessions (user_id)`,
`CREATE INDEX IF NOT EXISTS fs_sessions_project_idx ON fs_sessions ((data->>'projectId'))`,
// agent_sessions uses TEXT for project_id to match fs_projects.id
`CREATE TABLE IF NOT EXISTS agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
app_name TEXT NOT NULL,
app_path TEXT NOT NULL,
task TEXT NOT NULL,
plan JSONB,
status TEXT NOT NULL DEFAULT 'pending',
output JSONB NOT NULL DEFAULT '[]',
changed_files JSONB NOT NULL DEFAULT '[]',
error TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status)`,
`CREATE TABLE IF NOT EXISTS agent_session_events (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
project_id TEXT NOT NULL,
seq INT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
client_event_id UUID UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(session_id, seq)
)`,
`CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx ON agent_session_events (session_id, seq)`,
// NextAuth / Prisma tables
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT,
email TEXT UNIQUE,
email_verified TIMESTAMPTZ,
image TEXT
)`,
`CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT NOT NULL,
provider_account_id TEXT NOT NULL,
refresh_token TEXT,
access_token TEXT,
expires_at INTEGER,
token_type TEXT,
scope TEXT,
id_token TEXT,
session_state TEXT,
UNIQUE (provider, provider_account_id)
)`,
`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
session_token TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS verification_tokens (
identifier TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires TIMESTAMPTZ NOT NULL,
UNIQUE (identifier, token)
)`,
];
for (const stmt of statements) {
const label = stmt.trim().split("\n")[0].trim().slice(0, 80);
try {
await query(stmt, []);
results.push({ statement: label, ok: true });
} catch (err) {
results.push({
statement: label,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
const failed = results.filter(r => !r.ok);
return NextResponse.json(
{ ok: failed.length === 0, results },
{ status: failed.length === 0 ? 200 : 207 }
);
}

View File

@@ -1,151 +1,96 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
/**
* Store GitHub connection for authenticated user
* Encrypts and stores the access token securely
*/
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { accessToken, githubUser } = await request.json();
if (!accessToken || !githubUser) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// TODO: Encrypt the access token before storing
// For now, we'll store it directly (should use crypto.subtle or a library)
const encryptedToken = accessToken; // PLACEHOLDER
await query(
`UPDATE fs_users
SET data = data || $1::jsonb, updated_at = NOW()
WHERE data->>'email' = $2`,
[
JSON.stringify({
githubConnected: true,
githubUserId: githubUser.id,
githubUsername: githubUser.login,
githubName: githubUser.name,
githubEmail: githubUser.email,
githubAvatarUrl: githubUser.avatar_url,
githubAccessToken: accessToken,
githubConnectedAt: new Date().toISOString(),
}),
session.user.email,
]
);
// Store GitHub connection
const connectionRef = adminDb.collection('githubConnections').doc(userId);
await connectionRef.set({
userId,
githubUserId: githubUser.id,
githubUsername: githubUser.login,
githubName: githubUser.name,
githubEmail: githubUser.email,
githubAvatarUrl: githubUser.avatar_url,
accessToken: encryptedToken,
connectedAt: FieldValue.serverTimestamp(),
lastSyncedAt: null,
});
return NextResponse.json({
success: true,
githubUsername: githubUser.login,
});
return NextResponse.json({ success: true, githubUsername: githubUser.login });
} catch (error) {
console.error('[GitHub Connect] Error:', error);
return NextResponse.json(
{ error: 'Failed to store GitHub connection' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to store GitHub connection' }, { status: 500 });
}
}
/**
* Get GitHub connection status for authenticated user
*/
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
const rows = await query<{ data: any }>(
`SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[session.user.email]
);
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const connectionDoc = await adminDb
.collection('githubConnections')
.doc(userId)
.get();
if (!connectionDoc.exists) {
if (rows.length === 0 || !rows[0].data?.githubConnected) {
return NextResponse.json({ connected: false });
}
const data = connectionDoc.data()!;
const d = rows[0].data;
return NextResponse.json({
connected: true,
githubUsername: data.githubUsername,
githubName: data.githubName,
githubAvatarUrl: data.githubAvatarUrl,
connectedAt: data.connectedAt,
lastSyncedAt: data.lastSyncedAt,
githubUsername: d.githubUsername,
githubName: d.githubName,
githubAvatarUrl: d.githubAvatarUrl,
connectedAt: d.githubConnectedAt,
});
} catch (error) {
console.error('[GitHub Connect] Error:', error);
return NextResponse.json(
{ error: 'Failed to fetch GitHub connection' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to fetch GitHub connection' }, { status: 500 });
}
}
/**
* Disconnect GitHub account
*/
export async function DELETE(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
await adminDb.collection('githubConnections').doc(userId).delete();
await query(
`UPDATE fs_users
SET data = data - 'githubConnected' - 'githubUserId' - 'githubUsername'
- 'githubName' - 'githubEmail' - 'githubAvatarUrl'
- 'githubAccessToken' - 'githubConnectedAt',
updated_at = NOW()
WHERE data->>'email' = $1`,
[session.user.email]
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('[GitHub Disconnect] Error:', error);
return NextResponse.json(
{ error: 'Failed to disconnect GitHub' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to disconnect GitHub' }, { status: 500 });
}
}

View File

@@ -0,0 +1,204 @@
/**
* Assist COO — proxies to the agent runner's Orchestrator.
*
* The Orchestrator (Claude Sonnet 4.6, Tier B) has full tool access:
* Gitea — read repos, files, issues, commits
* Coolify — app status, deploy logs, trigger deploys
* Web search, memory, agent spawning
*
* This route loads project-specific context (PRD, phases, apps, sessions)
* and injects it as knowledge_context into the orchestrator's system prompt.
*/
import { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com';
// ---------------------------------------------------------------------------
// Context loader — everything the COO needs to know about the project
// ---------------------------------------------------------------------------
async function buildKnowledgeContext(projectId: string, email: string): Promise<string> {
const [projectRows, phaseRows, sessionRows] = await Promise.all([
query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, email]
).catch(() => [] as { data: Record<string, unknown> }[]),
query<{ phase: string; title: string; summary: string }>(
`SELECT phase, title, summary FROM atlas_phases
WHERE project_id = $1 ORDER BY saved_at ASC`,
[projectId]
).catch(() => [] as { phase: string; title: string; summary: string }[]),
query<{ task: string; status: string }>(
`SELECT data->>'task' as task, data->>'status' as status
FROM fs_sessions WHERE data->>'projectId' = $1
ORDER BY created_at DESC LIMIT 8`,
[projectId]
).catch(() => [] as { task: string; status: string }[]),
]);
const d = projectRows[0]?.data ?? {};
const name = (d.name as string) ?? 'Unknown Project';
const vision = (d.productVision as string) ?? (d.vision as string) ?? '';
const giteaRepo = (d.giteaRepo as string) ?? '';
const prd = (d.prd as string) ?? '';
const architecture = d.architecture as Record<string, unknown> | null ?? null;
const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? [];
const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? '';
const theiaUrl = (d.theiaWorkspaceUrl as string) ?? '';
const lines: string[] = [];
// COO persona — injected so the orchestrator knows its role for this session
lines.push(`## Your role for this conversation
You are the personal AI COO for "${name}" — a trusted executive partner to the founder.
The founder talks to you. You figure out what needs to happen and get it done.
You delegate to specialist agents (Coder, PM, Marketing) when work is needed.
Operating principles:
- Use your tools proactively. Don't guess — check Gitea for what's been built, check Coolify for app status.
- Before delegating any work: state the scope in plain English and confirm with the founder.
- Be brief. No preamble, no "Great question!".
- You decide the technical approach — never ask the founder to choose.
- Be honest when you're uncertain or when data isn't available.
- Do NOT spawn agents on the protected platform repos (vibn-frontend, theia-code-os, vibn-agent-runner, vibn-api, master-ai).`);
// Project identity
lines.push(`\n## Project: ${name}`);
if (vision) lines.push(`Vision: ${vision}`);
if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`);
if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`);
if (theiaUrl) lines.push(`Theia IDE: ${theiaUrl}`);
// Architecture document
if (architecture) {
const archApps = (architecture.apps as Array<{ name: string; type: string; description: string }> ?? [])
.map(a => ` - ${a.name} (${a.type}): ${a.description}`).join('\n');
const archInfra = (architecture.infrastructure as Array<{ name: string; reason: string }> ?? [])
.map(i => ` - ${i.name}: ${i.reason}`).join('\n');
lines.push(`\n## Technical Architecture\nSummary: ${architecture.summary ?? ''}\n\nApps:\n${archApps}\n\nInfrastructure:\n${archInfra}`);
}
// PRD or discovery phases
if (prd) {
// Claude Sonnet has a 200k token context — pass the full PRD, no truncation needed
lines.push(`\n## Product Requirements Document\n${prd}`);
} else if (phaseRows.length > 0) {
lines.push(`\n## Discovery phases completed (${phaseRows.length})`);
for (const p of phaseRows) {
lines.push(`- ${p.title}: ${p.summary}`);
}
lines.push(`(PRD not yet finalized — Vibn discovery is in progress)`);
} else {
lines.push(`\n## Product discovery: not yet started`);
}
// Deployed apps
if (apps.length > 0) {
lines.push(`\n## Deployed apps`);
for (const a of apps) {
const url = a.domain ? `https://${a.domain}` : '(no domain yet)';
const uuid = a.coolifyServiceUuid ? ` [uuid: ${a.coolifyServiceUuid}]` : '';
lines.push(`- ${a.name}${url}${uuid}`);
}
}
// Recent agent work
const validSessions = sessionRows.filter(s => s.task);
if (validSessions.length > 0) {
lines.push(`\n## Recent agent sessions (what's been worked on)`);
for (const s of validSessions) {
lines.push(`- [${s.status ?? 'unknown'}] ${s.task}`);
}
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// POST handler
// ---------------------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return new Response('Unauthorized', { status: 401 });
}
const { message, history = [] } = await req.json() as {
message: string;
history: Array<{ role: 'user' | 'model'; content: string }>;
};
if (!message?.trim()) {
return new Response('Message required', { status: 400 });
}
// Load project context (best-effort)
let knowledgeContext = '';
try {
knowledgeContext = await buildKnowledgeContext(projectId, session.user.email);
} catch { /* proceed without — orchestrator still works */ }
// Convert history: frontend uses "model", orchestrator uses "assistant"
const llmHistory = history
.filter(h => h.content?.trim())
.map(h => ({
role: (h.role === 'model' ? 'assistant' : 'user') as 'assistant' | 'user',
content: h.content,
}));
// Call the orchestrator on the agent runner
let orchRes: Response;
try {
orchRes = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
// Scoped session per project so in-memory context persists within a browser session
session_id: `coo_${projectId}_${session.user.email.split('@')[0]}`,
history: llmHistory,
knowledge_context: knowledgeContext,
}),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return new Response(`Agent runner unreachable: ${msg}`, { status: 502 });
}
if (!orchRes.ok) {
const err = await orchRes.text();
return new Response(`Orchestrator error: ${err}`, { status: 502 });
}
const result = await orchRes.json() as { reply?: string; error?: string };
if (result.error) {
return new Response(result.error, { status: 500 });
}
const reply = result.reply ?? '(no response)';
// Return as a streaming response — single chunk (orchestrator is non-streaming)
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(reply));
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}

View File

@@ -0,0 +1,209 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
// ---------------------------------------------------------------------------
// Helpers — chat_conversations + fs_knowledge_items
// ---------------------------------------------------------------------------
async function loadConversation(projectId: string): Promise<any[]> {
try {
const rows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
return rows[0]?.messages ?? [];
} catch {
return [];
}
}
async function saveConversation(projectId: string, messages: any[]): Promise<void> {
try {
await query(
`INSERT INTO chat_conversations (project_id, messages, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (project_id) DO UPDATE
SET messages = $2::jsonb, updated_at = NOW()`,
[projectId, JSON.stringify(messages)]
);
} catch (e) {
console.error("[agent-chat] Failed to save conversation:", e);
}
}
async function loadKnowledge(projectId: string): Promise<string> {
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC LIMIT 50`,
[projectId]
);
if (rows.length === 0) return "";
return rows
.map((r) => `[${r.data.type ?? "note"}] ${r.data.key}: ${r.data.value}`)
.join("\n");
} catch {
return "";
}
}
async function saveMemoryUpdates(
projectId: string,
updates: Array<{ key: string; type: string; value: string }>
): Promise<void> {
if (!updates?.length) return;
try {
for (const u of updates) {
// Upsert by project_id + key
const existing = await query<{ id: string }>(
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
[projectId, u.key]
);
if (existing.length > 0) {
await query(
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" }), existing[0].id]
);
} else {
await query(
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb)`,
[projectId, JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" })]
);
}
}
} catch (e) {
console.error("[agent-chat] Failed to save memory updates:", e);
}
}
// ---------------------------------------------------------------------------
// POST — send a message
// ---------------------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const { message } = await req.json();
if (!message?.trim()) {
return NextResponse.json({ error: '"message" is required' }, { status: 400 });
}
// Load project context
let projectContext = "";
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length > 0) {
const p = rows[0].data;
const lines = [
`Project: ${p.productName ?? p.name ?? "Unnamed"}`,
p.productVision ? `Vision: ${p.productVision}` : null,
p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null,
p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null,
p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null,
p.theiaWorkspaceUrl ? `IDE: ${p.theiaWorkspaceUrl}` : null,
].filter(Boolean);
projectContext = lines.join("\n");
}
} catch { /* non-fatal */ }
const sessionId = `project_${projectId}`;
// Load persistent conversation history and knowledge from DB
const [history, knowledgeContext] = await Promise.all([
loadConversation(projectId),
loadKnowledge(projectId),
]);
// Enrich user message with project context on the very first message
const isFirstMessage = history.length === 0;
const enrichedMessage =
isFirstMessage && projectContext
? `[Project context]\n${projectContext}\n\n[User message]\n${message}`
: message;
try {
const res = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: enrichedMessage,
session_id: sessionId,
history,
knowledge_context: knowledgeContext || undefined,
}),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
const errText = await res.text();
return NextResponse.json(
{ error: `Agent runner error: ${res.status}${errText.slice(0, 200)}` },
{ status: 502 }
);
}
const data = await res.json();
// Persist conversation and any memory updates the AI generated
await Promise.all([
saveConversation(projectId, data.history ?? []),
saveMemoryUpdates(projectId, data.memoryUpdates ?? []),
]);
return NextResponse.json({
reply: data.reply,
reasoning: data.reasoning ?? null,
toolCalls: data.toolCalls ?? [],
turns: data.turns ?? 0,
model: data.model || null,
sessionId,
memoryUpdates: data.memoryUpdates ?? [],
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ error: msg.includes("fetch") ? "Agent runner is offline" : msg },
{ status: 503 }
);
}
}
// ---------------------------------------------------------------------------
// DELETE — clear session + conversation history
// ---------------------------------------------------------------------------
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const sessionId = `project_${projectId}`;
await Promise.all([
// Clear in-memory session from agent runner
fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, { method: "DELETE" }).catch(() => {}),
// Clear persisted conversation from DB
query(`DELETE FROM chat_conversations WHERE project_id = $1`, [projectId]).catch(() => {}),
]);
return NextResponse.json({ cleared: true });
}

View File

@@ -0,0 +1,133 @@
/**
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/approve
*
* Called by the frontend when the user clicks "Approve & commit".
* Verifies ownership, then asks the agent runner to git commit + push
* the changes it made in the workspace, and triggers a Coolify deploy.
*
* Body: { commitMessage: string }
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
const COOLIFY_API_URL = process.env.COOLIFY_API_URL ?? "";
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
interface AppEntry {
name: string;
path: string;
coolifyServiceUuid?: string | null;
domain?: string | null;
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json() as { commitMessage?: string };
const commitMessage = body.commitMessage?.trim();
if (!commitMessage) {
return NextResponse.json({ error: "commitMessage is required" }, { status: 400 });
}
// Verify ownership + fetch project data (giteaRepo, apps list)
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const projectData = rows[0].data;
const giteaRepo = projectData?.giteaRepo as string | undefined;
if (!giteaRepo) {
return NextResponse.json({ error: "No Gitea repo linked to this project" }, { status: 400 });
}
// Find the session to get the appName (so we can find the right Coolify UUID)
const sessionRows = await query<{ app_name: string; status: string }>(
`SELECT app_name, status FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
[sessionId, projectId]
);
if (sessionRows.length === 0) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
if (sessionRows[0].status !== "done") {
return NextResponse.json({ error: "Session must be in 'done' state to approve" }, { status: 400 });
}
const appName = sessionRows[0].app_name;
// Find the matching Coolify UUID from project.data.apps[]
const apps: AppEntry[] = (projectData?.apps ?? []) as AppEntry[];
const matchedApp = apps.find(a => a.name === appName);
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
// Call agent runner to commit + push
const approveRes = await fetch(`${AGENT_RUNNER_URL}/agent/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
giteaRepo,
commitMessage,
coolifyApiUrl: COOLIFY_API_URL,
coolifyApiToken: COOLIFY_API_TOKEN,
coolifyAppUuid,
}),
});
const approveData = await approveRes.json() as {
ok: boolean;
committed?: boolean;
deployed?: boolean;
message?: string;
error?: string;
};
if (!approveRes.ok || !approveData.ok) {
return NextResponse.json(
{ error: approveData.error ?? "Agent runner returned an error" },
{ status: 500 }
);
}
// Mark session as approved in DB
await query(
`UPDATE agent_sessions
SET status = 'approved', completed_at = COALESCE(completed_at, now()), updated_at = now(),
output = output || $1::jsonb
WHERE id = $2::uuid`,
[
JSON.stringify([{
ts: new Date().toISOString(),
type: "done",
text: `${approveData.message ?? "Committed and pushed."}${approveData.deployed ? " Deployment triggered." : ""}`,
}]),
sessionId,
]
);
return NextResponse.json({
ok: true,
committed: approveData.committed,
deployed: approveData.deployed,
message: approveData.message,
});
} catch (err) {
console.error("[agent/approve]", err);
return NextResponse.json({ error: "Failed to approve session" }, { status: 500 });
}
}

View File

@@ -0,0 +1,142 @@
/**
* GET /api/projects/[projectId]/agent/sessions/[sessionId]/events?afterSeq=0
* List persisted agent events for replay (user session auth).
*
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/events
* Batch append from vibn-agent-runner (x-agent-runner-secret).
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query, getPool } from "@/lib/db-postgres";
export interface AgentSessionEventRow {
seq: number;
ts: string;
type: string;
payload: Record<string, unknown>;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
const rows = await query<AgentSessionEventRow>(
`SELECT e.seq, e.ts::text, e.type, e.payload
FROM agent_session_events e
JOIN agent_sessions s ON s.id = e.session_id
JOIN fs_projects p ON p.id::text = s.project_id::text
JOIN fs_users u ON u.id = p.user_id
WHERE e.session_id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
AND e.seq > $4
ORDER BY e.seq ASC
LIMIT 2000`,
[sessionId, projectId, session.user.email, afterSeq]
);
const maxSeq = rows.length ? rows[rows.length - 1].seq : afterSeq;
return NextResponse.json({ events: rows, maxSeq });
} catch (err) {
console.error("[agent/sessions/.../events GET]", err);
return NextResponse.json({ error: "Failed to list events" }, { status: 500 });
}
}
type IngestBody = {
events: Array<{
clientEventId: string;
ts: string;
type: string;
payload?: Record<string, unknown>;
}>;
};
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
const secret = process.env.AGENT_RUNNER_SECRET ?? "";
const incomingSecret = req.headers.get("x-agent-runner-secret") ?? "";
if (secret && incomingSecret !== secret) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { projectId, sessionId } = await params;
let body: IngestBody;
try {
body = (await req.json()) as IngestBody;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.events?.length) {
return NextResponse.json({ ok: true, inserted: 0 });
}
const pool = getPool();
const client = await pool.connect();
try {
const exists = await client.query<{ n: string }>(
`SELECT 1 AS n FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
[sessionId, projectId]
);
if (exists.rowCount === 0) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock(hashtext($1::text))", [sessionId]);
let inserted = 0;
for (const ev of body.events) {
if (!ev.clientEventId || !ev.type || !ev.ts) continue;
const maxRes = await client.query<{ m: string }>(
`SELECT COALESCE(MAX(seq), 0)::text AS m FROM agent_session_events WHERE session_id = $1::uuid`,
[sessionId]
);
const nextSeq = Number(maxRes.rows[0].m) + 1;
const ins = await client.query(
`INSERT INTO agent_session_events (session_id, project_id, seq, ts, type, payload, client_event_id)
VALUES ($1::uuid, $2, $3, $4::timestamptz, $5, $6::jsonb, $7::uuid)
ON CONFLICT (client_event_id) DO NOTHING`,
[
sessionId,
projectId,
nextSeq,
ev.ts,
ev.type,
JSON.stringify(ev.payload ?? {}),
ev.clientEventId,
]
);
if (ins.rowCount) inserted += ins.rowCount;
}
await client.query("COMMIT");
return NextResponse.json({ ok: true, inserted });
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
/* ignore */
}
console.error("[agent/sessions/.../events POST]", err);
return NextResponse.json({ error: "Failed to ingest events" }, { status: 500 });
} finally {
client.release();
}
}

View File

@@ -0,0 +1,122 @@
/**
* GET /api/projects/.../agent/sessions/.../events/stream?afterSeq=0
* Server-Sent Events: tail agent_session_events while the session is active.
*/
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query, queryOne } from "@/lib/db-postgres";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/** Long-lived SSE — raise if your host defaults to a shorter limit (e.g. Vercel). */
export const maxDuration = 300;
const TERMINAL = new Set(["done", "approved", "failed", "stopped"]);
export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return new Response("Unauthorized", { status: 401 });
}
const { projectId, sessionId } = await params;
let afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
const allowed = await queryOne<{ n: string }>(
`SELECT 1 AS n FROM agent_sessions s
JOIN fs_projects p ON p.id::text = s.project_id::text
JOIN fs_users u ON u.id = p.user_id
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
LIMIT 1`,
[sessionId, projectId, session.user.email]
);
if (!allowed) {
return new Response("Not found", { status: 404 });
}
const encoder = new TextEncoder();
const signal = req.signal;
const stream = new ReadableStream({
async start(controller) {
const send = (obj: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
};
let idleAfterTerminal = 0;
let lastHeartbeat = Date.now();
try {
while (!signal.aborted) {
const rows = await query<{ seq: number; ts: string; type: string; payload: Record<string, unknown> }>(
`SELECT e.seq, e.ts::text, e.type, e.payload
FROM agent_session_events e
WHERE e.session_id = $1::uuid AND e.seq > $2
ORDER BY e.seq ASC
LIMIT 200`,
[sessionId, afterSeq]
);
for (const row of rows) {
afterSeq = row.seq;
send({ seq: row.seq, ts: row.ts, type: row.type, payload: row.payload });
}
const st = await queryOne<{ status: string }>(
`SELECT status FROM agent_sessions WHERE id = $1::uuid LIMIT 1`,
[sessionId]
);
const status = st?.status ?? "";
const terminal = TERMINAL.has(status);
if (rows.length === 0) {
if (terminal) {
idleAfterTerminal++;
if (idleAfterTerminal >= 3) {
send({ type: "_stream.end", seq: afterSeq });
break;
}
} else {
idleAfterTerminal = 0;
}
} else {
idleAfterTerminal = 0;
}
const now = Date.now();
if (now - lastHeartbeat > 20000) {
send({ type: "_heartbeat", t: now });
lastHeartbeat = now;
}
await new Promise((r) => setTimeout(r, 750));
}
} catch (e) {
console.error("[events/stream]", e);
try {
send({ type: "_stream.error", message: "stream failed" });
} catch {
/* ignore */
}
} finally {
try {
controller.close();
} catch {
/* ignore */
}
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@@ -0,0 +1,121 @@
/**
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/retry
*
* Re-run a failed or stopped session, optionally with a follow-up instruction.
* Resets the session row to `running` and fires the agent-runner again.
*
* Body: { continueTask?: string }
* continueTask — if provided, appended to the original task so the agent
* understands what was already tried
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => ({})) as { continueTask?: string };
// Verify ownership and load the original session
const rows = await query<{
id: string;
project_id: string;
app_name: string;
app_path: string;
task: string;
status: string;
}>(
`SELECT s.id, s.project_id, s.app_name, s.app_path, s.task, s.status
FROM agent_sessions s
JOIN fs_projects p ON p.id::text = s.project_id::text
JOIN fs_users u ON u.id = p.user_id
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
LIMIT 1`,
[sessionId, projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const s = rows[0];
if (!["failed", "stopped"].includes(s.status)) {
return NextResponse.json(
{ error: `Session is ${s.status} — can only retry failed or stopped sessions` },
{ status: 409 }
);
}
// Fetch giteaRepo from the project
const proj = await query<{ data: Record<string, unknown> }>(
`SELECT data FROM fs_projects WHERE id::text = $1 LIMIT 1`,
[projectId]
);
const giteaRepo = proj[0]?.data?.giteaRepo as string | undefined;
// Clear persisted event timeline so SSE / replay matches the new run (no-op if table missing)
try {
await query(`DELETE FROM agent_session_events WHERE session_id = $1::uuid`, [sessionId]);
} catch {
/* table may not exist until admin migrate */
}
// Reset the session row so the frontend shows it as running again
await query(
`UPDATE agent_sessions
SET status = 'running',
error = NULL,
output = '[]'::jsonb,
changed_files = '[]'::jsonb,
started_at = now(),
completed_at = NULL,
updated_at = now()
WHERE id = $1::uuid`,
[sessionId]
);
// Re-fire the agent runner
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId,
projectId,
appName: s.app_name,
appPath: s.app_path,
giteaRepo,
task: s.task,
continueTask: body.continueTask?.trim() || undefined,
}),
}).catch(err => {
console.warn("[retry] runner not reachable:", err.message);
query(
`UPDATE agent_sessions
SET status = 'failed', error = 'Agent runner not reachable', completed_at = now(), updated_at = now()
WHERE id = $1::uuid`,
[sessionId]
).catch(() => {});
});
return NextResponse.json({ sessionId, status: "running" });
} catch (err) {
console.error("[retry POST]", err);
return NextResponse.json(
{ error: "Failed to retry session", details: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,122 @@
/**
* GET /api/projects/[projectId]/agent/sessions/[sessionId]
* Fetch a session's full state — status, output log, changed files.
* Frontend polls this (or will switch to WebSocket in Phase 3).
*
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/stop
* (handled in /stop/route.ts)
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const rows = await query<{
id: string;
app_name: string;
app_path: string;
task: string;
plan: unknown;
status: string;
output: Array<{ ts: string; type: string; text: string }>;
changed_files: Array<{ path: string; status: string }>;
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
}>(
`SELECT s.id, s.app_name, s.app_path, s.task, s.plan,
s.status, s.output, s.changed_files, s.error,
s.created_at, s.started_at, s.completed_at
FROM agent_sessions s
JOIN fs_projects p ON p.id::text = s.project_id::text
JOIN fs_users u ON u.id = p.user_id
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
LIMIT 1`,
[sessionId, projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
return NextResponse.json({ session: rows[0] });
} catch (err) {
console.error("[agent/sessions/[id] GET]", err);
return NextResponse.json({ error: "Failed to fetch session" }, { status: 500 });
}
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
/**
* Internal endpoint called by vibn-agent-runner to append output lines
* and update status. Requires x-agent-runner-secret header.
*/
const secret = process.env.AGENT_RUNNER_SECRET ?? "";
const incomingSecret = req.headers.get("x-agent-runner-secret") ?? "";
if (secret && incomingSecret !== secret) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { sessionId } = await params;
const body = await req.json() as {
status?: string;
outputLine?: { ts: string; type: string; text: string };
changedFile?: { path: string; status: string };
error?: string;
};
const updates: string[] = ["updated_at = now()"];
const values: unknown[] = [];
let idx = 1;
if (body.status) {
updates.push(`status = $${idx++}`);
values.push(body.status);
if (["done", "approved", "failed", "stopped"].includes(body.status)) {
updates.push(`completed_at = now()`);
}
}
if (body.error) {
updates.push(`error = $${idx++}`);
values.push(body.error);
}
if (body.outputLine) {
updates.push(`output = output || $${idx++}::jsonb`);
values.push(JSON.stringify([body.outputLine]));
}
if (body.changedFile) {
updates.push(`changed_files = changed_files || $${idx++}::jsonb`);
values.push(JSON.stringify([body.changedFile]));
}
values.push(sessionId);
await query(
`UPDATE agent_sessions SET ${updates.join(", ")} WHERE id = $${idx}::uuid`,
values
);
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[agent/sessions/[id] PATCH]", err);
return NextResponse.json({ error: "Failed to update session" }, { status: 500 });
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
export async function POST(
_req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Verify ownership
const rows = await query<{ status: string }>(
`SELECT s.status FROM agent_sessions s
JOIN fs_projects p ON p.id::text = s.project_id::text
JOIN fs_users u ON u.id = p.user_id
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3 LIMIT 1`,
[sessionId, projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
if (rows[0].status !== "running" && rows[0].status !== "pending") {
return NextResponse.json({ error: "Session is not running" }, { status: 400 });
}
// Tell the agent runner to stop (best-effort)
fetch(`${AGENT_RUNNER_URL}/agent/stop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
}).catch(() => {});
// Mark as stopped in DB immediately
await query(
`UPDATE agent_sessions
SET status = 'stopped', completed_at = now(), updated_at = now(),
output = output || '[{"ts": "now", "type": "info", "text": "Stopped by user."}]'::jsonb
WHERE id = $1::uuid`,
[sessionId]
);
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[agent/sessions/stop]", err);
return NextResponse.json({ error: "Failed to stop session" }, { status: 500 });
}
}

View File

@@ -0,0 +1,173 @@
/**
* Agent Sessions API
*
* POST /api/projects/[projectId]/agent/sessions
* Create a new agent session and kick it off via vibn-agent-runner.
* Body: { appName, appPath, task }
*
* GET /api/projects/[projectId]/agent/sessions
* List all sessions for a project, newest first.
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
// Verify the agent_sessions table is reachable. If it doesn't exist yet,
// throw a descriptive error instead of a generic "Failed to create session".
// Run POST /api/admin/migrate once to create the table.
async function ensureTable() {
await query(
`SELECT 1 FROM agent_sessions LIMIT 0`,
[]
);
}
// ── POST — create session ────────────────────────────────────────────────────
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { appName, appPath, task } = body as {
appName: string;
appPath: string;
task: string;
};
if (!appName || !appPath || !task?.trim()) {
return NextResponse.json({ error: "appName, appPath and task are required" }, { status: 400 });
}
await ensureTable();
// Verify ownership and fetch giteaRepo
const owns = await query<{ id: string; data: Record<string, unknown> }>(
`SELECT p.id, p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (owns.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const giteaRepo = owns[0].data?.giteaRepo as string | undefined;
// Find the Coolify UUID for this specific app so the runner can trigger a deploy
interface AppEntry { name: string; coolifyServiceUuid?: string | null; }
const apps = (owns[0].data?.apps ?? []) as AppEntry[];
const matchedApp = apps.find(a => a.name === appName);
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
// Create the session row
const rows = await query<{ id: string }>(
`INSERT INTO agent_sessions (project_id, app_name, app_path, task, status, started_at)
VALUES ($1::text, $2, $3, $4, 'running', now())
RETURNING id`,
[projectId, appName, appPath, task.trim()]
);
const sessionId = rows[0].id;
// Fire-and-forget: call agent-runner to start the execution loop.
// autoApprove: true — agent commits + deploys automatically on completion.
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId,
projectId,
appName,
appPath,
giteaRepo,
task: task.trim(),
autoApprove: true,
coolifyAppUuid,
}),
}).catch(err => {
// Agent runner may not be wired yet — log but don't fail
console.warn("[agent] runner not reachable:", err.message);
// Mark session as failed if runner unreachable
query(
`UPDATE agent_sessions
SET status = 'failed',
error = 'Agent runner not reachable',
completed_at = now(),
output = jsonb_build_array(jsonb_build_object(
'ts', now()::text,
'type', 'error',
'text', 'Agent runner service is not connected yet. Phase 2 implementation pending.'
))
WHERE id = $1::uuid`,
[sessionId]
).catch(() => {});
});
return NextResponse.json({ sessionId }, { status: 201 });
} catch (err) {
console.error("[agent/sessions POST]", err);
return NextResponse.json(
{ error: "Failed to create session", details: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}
// ── GET — list sessions ──────────────────────────────────────────────────────
export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await ensureTable();
const sessions = await query<{
id: string;
app_name: string;
task: string;
status: string;
created_at: string;
started_at: string | null;
completed_at: string | null;
output: Array<{ ts: string; type: string; text: string }>;
changed_files: Array<{ path: string; status: string }>;
error: string | null;
}>(
`SELECT s.id, s.app_name, s.task, s.status,
s.created_at, s.started_at, s.completed_at,
s.output, s.changed_files, s.error
FROM agent_sessions s
JOIN fs_projects p ON p.id::text = s.project_id::text
JOIN fs_users u ON u.id = p.user_id
WHERE s.project_id::text = $1 AND u.data->>'email' = $2
ORDER BY s.created_at DESC
LIMIT 50`,
[projectId, session.user.email]
);
return NextResponse.json({ sessions });
} catch (err) {
console.error("[agent/sessions GET]", err);
return NextResponse.json(
{ error: "Failed to list sessions", details: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const data = rows[0].data ?? {};
const stage = (data.analysisStage as string) ?? 'cloning';
const analysisResult = stage === 'done' ? data.analysisResult : undefined;
return NextResponse.json({ stage, analysisResult });
} catch (err) {
console.error('[analysis-status]', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export const maxDuration = 60;
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
async function callGemini(prompt: string): Promise<string> {
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 4096 },
}),
});
const data = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
return text;
}
function parseJsonBlock(raw: string): unknown {
const trimmed = raw.trim();
const cleaned = trimmed.startsWith('```')
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
: trimmed;
return JSON.parse(cleaned);
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json() as { chatText?: string };
const chatText = body.chatText?.trim() || '';
if (!chatText) {
return NextResponse.json({ error: 'chatText is required' }, { status: 400 });
}
// Verify project ownership
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const extractionPrompt = `You are a product analyst. A founder has pasted AI chat conversation history below.
Extract and categorise the following from those conversations. Return ONLY valid JSON — no markdown, no explanation.
JSON schema:
{
"decisions": ["string — concrete decisions already made"],
"ideas": ["string — product ideas and features mentioned"],
"openQuestions": ["string — unresolved questions that still need answers"],
"architecture": ["string — technical architecture notes, stack choices, infra decisions"],
"targetUsers": ["string — user segments, personas, or target audiences mentioned"]
}
Each array can be empty if nothing was found for that category. Extract real content — be specific and concise. Max 10 items per bucket.
--- CHAT HISTORY START ---
${chatText.slice(0, 12000)}
--- CHAT HISTORY END ---
Return only the JSON object:`;
const raw = await callGemini(extractionPrompt);
let analysisResult: {
decisions: string[];
ideas: string[];
openQuestions: string[];
architecture: string[];
targetUsers: string[];
};
try {
analysisResult = parseJsonBlock(raw) as typeof analysisResult;
} catch {
// Fallback: return empty buckets with a note
analysisResult = {
decisions: [],
ideas: [],
openQuestions: ["Could not parse extracted insights — try pasting more structured conversation"],
architecture: [],
targetUsers: [],
};
}
// Save analysis result to project data
const current = rows[0].data ?? {};
const updated = {
...current,
analysisResult,
creationStage: 'review',
updatedAt: new Date().toISOString(),
};
await query(
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
[projectId, JSON.stringify(updated)]
);
return NextResponse.json({ analysisResult });
} catch (err) {
console.error('[analyze-chats]', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,216 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
import { execSync } from 'child_process';
import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
import { join } from 'path';
export const maxDuration = 120;
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
async function callGemini(prompt: string): Promise<string> {
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 6000 },
}),
});
const data = await res.json();
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}
function parseJsonBlock(raw: string): unknown {
const trimmed = raw.trim();
const cleaned = trimmed.startsWith('```')
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
: trimmed;
return JSON.parse(cleaned);
}
// Read a file safely, returning empty string on failure
function safeRead(path: string, maxBytes = 8000): string {
try {
if (!existsSync(path)) return '';
const content = readFileSync(path, 'utf8');
return content.slice(0, maxBytes);
} catch {
return '';
}
}
// Walk directory and collect file listing (relative paths), limited to avoid huge outputs
function walkDir(dir: string, depth = 0, maxDepth = 4, acc: string[] = []): string[] {
if (depth > maxDepth) return acc;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__' || e.name === '.git') continue;
const full = join(dir, e.name);
const rel = full.replace(dir + '/', '');
if (e.isDirectory()) {
acc.push(rel + '/');
walkDir(full, depth + 1, maxDepth, acc);
} else {
acc.push(rel);
}
}
} catch { /* skip */ }
return acc;
}
async function updateStage(projectId: string, currentData: Record<string, unknown>, stage: string) {
const updated = { ...currentData, analysisStage: stage, updatedAt: new Date().toISOString() };
await query(
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
[projectId, JSON.stringify(updated)]
);
return updated;
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json() as { repoUrl?: string };
const repoUrl = body.repoUrl?.trim() || '';
if (!repoUrl.startsWith('http')) {
return NextResponse.json({ error: 'Invalid repository URL' }, { status: 400 });
}
// Verify ownership
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
let currentData = rows[0].data ?? {};
currentData = await updateStage(projectId, currentData, 'cloning');
// Clone repo into temp dir (fire and forget — status is polled separately)
const tmpDir = `/tmp/vibn-${projectId}`;
// Run async so the request returns quickly and client can poll
setImmediate(async () => {
try {
// Clean up any existing clone
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
execSync(`git clone --depth=1 "${repoUrl}" "${tmpDir}"`, {
timeout: 60_000,
stdio: 'ignore',
});
let data = { ...currentData };
data = await updateStage(projectId, data, 'reading');
// Read key files
const manifest: Record<string, string> = {};
const keyFiles = [
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
'requirements.txt', 'Pipfile', 'pyproject.toml',
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
'README.md', '.env.example', '.env.sample',
'next.config.js', 'next.config.ts', 'next.config.mjs',
'vite.config.ts', 'vite.config.js',
'tsconfig.json',
'prisma/schema.prisma', 'schema.prisma',
];
for (const f of keyFiles) {
const content = safeRead(join(tmpDir, f));
if (content) manifest[f] = content;
}
const fileListing = walkDir(tmpDir).slice(0, 300).join('\n');
data = await updateStage(projectId, data, 'analyzing');
const analysisPrompt = `You are a senior full-stack architect. Analyse this repository and return a structured architecture map.
File listing (top-level):
${fileListing}
Key file contents:
${Object.entries(manifest).map(([k, v]) => `\n### ${k}\n${v}`).join('')}
Return ONLY valid JSON with this structure:
{
"summary": "1-2 sentence project summary",
"rows": [
{ "category": "Tech Stack", "item": "Next.js 15", "status": "found", "detail": "next.config.ts present" },
{ "category": "Database", "item": "PostgreSQL", "status": "found", "detail": "prisma/schema.prisma detected" },
{ "category": "Auth", "item": "Authentication", "status": "missing", "detail": "No auth library detected" }
],
"suggestedSurfaces": ["marketing", "admin"]
}
Categories to cover: Tech Stack, Infrastructure, Database, API Surface, Frontend, Auth, Third-party, Missing / Gaps
Status values: "found", "partial", "missing"
suggestedSurfaces should only include items from: ["marketing", "web-app", "admin", "api"]
Suggest surfaces that are MISSING or incomplete in the current codebase.
Return only the JSON:`;
const raw = await callGemini(analysisPrompt);
let analysisResult;
try {
analysisResult = parseJsonBlock(raw);
} catch {
analysisResult = {
summary: 'Could not fully parse the repository structure.',
rows: [{ category: 'Tech Stack', item: 'Repository detected', status: 'found', detail: fileListing.split('\n').slice(0, 5).join(', ') }],
suggestedSurfaces: ['marketing'],
};
}
// Save result and mark done
const finalData = {
...data,
analysisStage: 'done',
analysisResult,
creationStage: 'mapping',
sourceData: { ...(data.sourceData as object || {}), repoUrl },
updatedAt: new Date().toISOString(),
};
await query(
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
[projectId, JSON.stringify(finalData)]
);
} catch (err) {
console.error('[analyze-repo] background error', err);
await query(
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
[projectId, JSON.stringify({ ...currentData, analysisStage: 'error', analysisError: String(err) })]
);
} finally {
// Clean up
try { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
}
});
return NextResponse.json({ started: true });
} catch (err) {
console.error('[analyze-repo]', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,121 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
// GET — check the current analysis status for a project
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { projectId } = await params;
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
const project = rows[0].data;
if (!project.isImport) {
return NextResponse.json({ isImport: false });
}
const jobId = project.importAnalysisJobId;
let jobStatus: Record<string, unknown> | null = null;
// Fetch live job status from agent runner if we have a job ID
if (jobId) {
try {
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/jobs/${jobId}`);
if (jobRes.ok) {
jobStatus = await jobRes.json() as Record<string, unknown>;
// Sync terminal status back to the project record
const runnerStatus = jobStatus.status as string | undefined;
if (runnerStatus && runnerStatus !== project.importAnalysisStatus) {
await query(
`UPDATE fs_projects SET data = jsonb_set(data, '{importAnalysisStatus}', $1::jsonb) WHERE id = $2`,
[JSON.stringify(runnerStatus), projectId]
);
}
}
} catch {
// Agent runner unreachable — return last known status
}
}
return NextResponse.json({
isImport: true,
status: project.importAnalysisStatus ?? 'pending',
jobId,
job: jobStatus,
githubRepoUrl: project.githubRepoUrl,
giteaRepo: project.giteaRepo,
});
}
// POST — (re-)trigger an analysis job for a project
export async function POST(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { projectId } = await params;
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
const project = rows[0].data;
if (!project.giteaRepo) {
return NextResponse.json({ error: 'Project has no Gitea repo' }, { status: 400 });
}
try {
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/agent/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent: 'ImportAnalyzer',
task: `Analyze this codebase${project.githubRepoUrl ? ` (originally from ${project.githubRepoUrl})` : ''} and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
repo: project.giteaRepo,
}),
});
if (!jobRes.ok) {
const detail = await jobRes.text();
return NextResponse.json({ error: 'Failed to start analysis', details: detail }, { status: 500 });
}
const jobData = await jobRes.json() as { jobId?: string };
const jobId = jobData.jobId ?? null;
await query(
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
[JSON.stringify(jobId), projectId]
);
return NextResponse.json({ success: true, jobId, status: 'running' });
} catch (err) {
return NextResponse.json(
{ error: 'Failed to start analysis', details: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,152 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
async function giteaGet(path: string) {
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
next: { revalidate: 30 },
});
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
return res.json();
}
/**
* GET — returns the project's apps/ directories from Gitea + saved designPackages.
* Response: { apps: [{ name, path, type }], designPackages: { appName: packageId } }
*/
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const data = rows[0].data ?? {};
const giteaRepo = data.giteaRepo as string | undefined; // e.g. "mark/sportsy"
const designPackages = (data.designPackages ?? {}) as Record<string, string>;
let apps: { name: string; path: string }[] = [];
if (giteaRepo) {
// First: try the standard turborepo apps/ directory
try {
const contents: Array<{ name: string; path: string; type: string }> =
await giteaGet(`/repos/${giteaRepo}/contents/apps`);
apps = contents
.filter((item) => item.type === 'dir')
.map(({ name, path }) => ({ name, path }));
} catch {
// No apps/ dir — fall through to import detection below
}
// Fallback: no apps/ dir — scan repo root for deployable components.
// Works for any project structure (imported, single-repo, monorepo variants).
if (apps.length === 0) {
try {
// Try CODEBASE_MAP.md first (written by ImportAnalyzer for imported repos)
const mapFile = await giteaGet(`/repos/${giteaRepo}/contents/CODEBASE_MAP.md`).catch(() => null);
if (mapFile?.content) {
const decoded = Buffer.from(mapFile.content, 'base64').toString('utf-8');
const matches = [...decoded.matchAll(/###\s+.+?[—–-]\s+[`]?([^`\n(]+)[`]?/g)];
const parsedApps = matches
.map(m => m[1].trim().replace(/^`|`$/g, '').replace(/\/$/, ''))
.filter(p => p && p.length > 0 && !p.includes(' ') && !p.startsWith('http') && p !== '.')
.map(p => ({ name: p.split('/').pop() ?? p, path: p }));
if (parsedApps.length > 0) apps = parsedApps;
}
} catch { /* CODEBASE_MAP not available */ }
// Scan top-level dirs for app signals
if (apps.length === 0) {
try {
const SKIP = new Set(['docs', 'scripts', 'keys', '.github', 'node_modules', '.git', 'dist', 'build', 'coverage']);
const APP_SIGNALS = ['package.json', 'requirements.txt', 'pyproject.toml', 'Dockerfile', 'next.config.ts', 'next.config.js', 'vite.config.ts', 'main.py', 'app.py', 'index.js', 'server.ts'];
const root: Array<{ name: string; path: string; type: string }> =
await giteaGet(`/repos/${giteaRepo}/contents/`);
// Check if the root itself is an app (single-repo projects)
const rootIsApp = root.some(f => f.type === 'file' && APP_SIGNALS.includes(f.name));
if (rootIsApp) {
// Repo root is the app — use repo name as label, empty string as path
apps = [{ name: giteaRepo.split('/').pop() ?? 'app', path: '' }];
} else {
// Scan subdirs
const dirs = root.filter(i => i.type === 'dir' && !SKIP.has(i.name));
const candidates = await Promise.all(
dirs.map(async (dir) => {
try {
const sub: Array<{ name: string; type: string }> = await giteaGet(`/repos/${giteaRepo}/contents/${dir.path}`);
return sub.some(f => APP_SIGNALS.includes(f.name)) ? { name: dir.name, path: dir.path } : null;
} catch { return null; }
})
);
apps = candidates.filter((a): a is { name: string; path: string } => a !== null);
}
} catch { /* scan failed */ }
}
// Last resort: expose the repo root so the file tree still works
if (apps.length === 0) {
apps = [{ name: giteaRepo.split('/').pop() ?? 'app', path: '' }];
}
}
}
return NextResponse.json({ apps, designPackages, giteaRepo, isImport: !!(data.isImport || data.creationMode === 'migration') });
}
/**
* PATCH — saves { appName, packageId } → stored in fs_projects.data.designPackages
*/
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { appName, packageId } = await req.json() as { appName: string; packageId: string };
if (!appName || !packageId) {
return NextResponse.json({ error: 'appName and packageId are required' }, { status: 400 });
}
await query(
`UPDATE fs_projects p
SET data = data || jsonb_build_object(
'designPackages',
COALESCE(data->'designPackages', '{}'::jsonb) || jsonb_build_object($3, $4)
),
updated_at = NOW()
FROM fs_users u
WHERE p.id = $1 AND p.user_id = u.id AND u.data->>'email' = $2`,
[projectId, session.user.email, appName, packageId]
);
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
// ---------------------------------------------------------------------------
// GET — return saved architecture (if it exists)
// ---------------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
const data = rows[0]?.data ?? {};
return NextResponse.json({
architecture: data.architecture ?? null,
prd: data.prd ?? null,
});
} catch {
return NextResponse.json({ architecture: null, prd: null });
}
}
// ---------------------------------------------------------------------------
// POST — generate architecture recommendation from PRD using AI
// ---------------------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const body = await req.json().catch(() => ({}));
const forceRegenerate = body.forceRegenerate === true;
// Load project PRD + phases
let prd: string | null = null;
let phases: any[] = [];
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
const data = rows[0]?.data ?? {};
prd = data.prd ?? null;
// Return cached architecture if it exists and not forcing regenerate
if (data.architecture && !forceRegenerate) {
return NextResponse.json({ architecture: data.architecture, cached: true });
}
} catch {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
if (!prd) {
return NextResponse.json({ error: "No PRD found — complete discovery first" }, { status: 400 });
}
try {
const phaseRows = await query<{ phase: string; title: string; summary: string; data: any }>(
`SELECT phase, title, summary, data FROM atlas_phases WHERE project_id = $1 ORDER BY saved_at ASC`,
[projectId]
);
phases = phaseRows;
} catch { /* phases optional */ }
// Build a concise context string from phases
const phaseContext = phases.map(p =>
`## ${p.title}\n${p.summary}\n${JSON.stringify(p.data, null, 2)}`
).join("\n\n");
const prompt = `You are a senior software architect. Analyse the following Product Requirements Document and recommend a technical architecture for a Turborepo monorepo.
Return ONLY a valid JSON object (no markdown, no explanation) with this exact structure:
{
"productName": "string",
"productType": "string (e.g. PWA Game, SaaS, Marketplace, Internal Tool)",
"summary": "2-3 sentence plain-English summary of the recommended architecture",
"apps": [
{
"name": "string (e.g. web, api, simulator)",
"type": "string (e.g. Next.js 15, Express API, Node.js service)",
"description": "string — what this app does",
"tech": ["string array of key technologies"],
"screens": ["string array — key screens/routes if applicable, else empty"]
}
],
"packages": [
{
"name": "string (e.g. db, types, ui)",
"description": "string — what this shared package contains"
}
],
"infrastructure": [
{
"name": "string (e.g. PostgreSQL, Redis, Background Jobs)",
"reason": "string — why this is needed based on the PRD"
}
],
"integrations": [
{
"name": "string (e.g. Ad Network SDK)",
"required": true,
"notes": "string"
}
],
"designSurfaces": ["string array — e.g. Web App, Mobile PWA, Admin"],
"riskNotes": ["string array — 1-2 key architectural risks from the PRD"]
}
Be specific to this product. Do not use generic boilerplate — base your decisions on the PRD content.
--- DISCOVERY PHASES ---
${phaseContext}
--- PRD ---
${prd}`;
try {
const res = await fetch(`${AGENT_RUNNER_URL}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
throw new Error(`Agent runner responded ${res.status}`);
}
const data = await res.json();
const raw = data.reply ?? "";
// Extract JSON from response (strip any accidental markdown)
const jsonMatch = raw.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("No JSON in response");
const architecture = JSON.parse(jsonMatch[0]);
// Persist to project data
await query(
`UPDATE fs_projects
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architecture}', $2::jsonb, true),
updated_at = NOW()
WHERE id = $1`,
[projectId, JSON.stringify(architecture)]
);
return NextResponse.json({ architecture, cached: false });
} catch (err) {
console.error("[architecture] Generation failed:", err);
return NextResponse.json(
{ error: "Architecture generation failed. Please try again." },
{ status: 500 }
);
}
}
// ---------------------------------------------------------------------------
// PATCH — confirm architecture (sets architectureConfirmed flag)
// ---------------------------------------------------------------------------
export async function PATCH(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
try {
await query(
`UPDATE fs_projects
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architectureConfirmed}', 'true'::jsonb, true),
updated_at = NOW()
WHERE id = $1`,
[projectId]
);
return NextResponse.json({ confirmed: true });
} catch {
return NextResponse.json({ error: "Failed to confirm" }, { status: 500 });
}
}

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
// ---------------------------------------------------------------------------
// DB helpers — atlas_conversations table
// ---------------------------------------------------------------------------
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS atlas_conversations (
project_id TEXT PRIMARY KEY,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
tableReady = true;
}
async function loadAtlasHistory(projectId: string): Promise<any[]> {
try {
await ensureTable();
const rows = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
[projectId]
);
return rows[0]?.messages ?? [];
} catch {
return [];
}
}
async function saveAtlasHistory(projectId: string, messages: any[]): Promise<void> {
try {
await ensureTable();
await query(
`INSERT INTO atlas_conversations (project_id, messages, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (project_id) DO UPDATE
SET messages = $2::jsonb, updated_at = NOW()`,
[projectId, JSON.stringify(messages)]
);
} catch (e) {
console.error("[atlas-chat] Failed to save history:", e);
}
}
async function savePrd(projectId: string, prdContent: string): Promise<void> {
try {
await query(
`UPDATE fs_projects
SET data = data || jsonb_build_object('prd', $2::text, 'stage', 'architecture'),
updated_at = NOW()
WHERE id = $1`,
[projectId, prdContent]
);
console.log(`[atlas-chat] PRD saved for project ${projectId}`);
} catch (e) {
console.error("[atlas-chat] Failed to save PRD:", e);
}
}
// ---------------------------------------------------------------------------
// GET — load stored conversation messages for display
// ---------------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const history = await loadAtlasHistory(projectId);
// Filter to only user/assistant messages (no system prompts) for display
const messages = history
.filter((m: any) => m.role === "user" || m.role === "assistant")
.map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content as string }));
return NextResponse.json({ messages });
}
// ---------------------------------------------------------------------------
// POST — send message to Atlas
// ---------------------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const { message } = await req.json();
if (!message?.trim()) {
return NextResponse.json({ error: "message is required" }, { status: 400 });
}
const sessionId = `atlas_${projectId}`;
// Load conversation history from DB to persist across agent runner restarts.
// Strip tool_call / tool_response messages — replaying them across sessions
// causes Gemini to reject the request with a turn-ordering error.
const rawHistory = await loadAtlasHistory(projectId);
const history = rawHistory.filter((m: any) =>
(m.role === "user" || m.role === "assistant") && m.content
);
// __init__ is a special internal trigger used only when there is no existing history.
// If history already exists, ignore the init request (conversation already started).
const isInit = message.trim() === "__atlas_init__";
if (isInit && history.length > 0) {
return NextResponse.json({ reply: null, alreadyStarted: true });
}
try {
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// For init, send the greeting prompt but don't store it as a user message
message: isInit
? "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."
: message,
session_id: sessionId,
history,
is_init: isInit,
}),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
const text = await res.text();
console.error("[atlas-chat] Agent runner error:", text);
return NextResponse.json(
{ error: "Vibn is unavailable. Please try again." },
{ status: 502 }
);
}
const data = await res.json();
// Persist updated history
await saveAtlasHistory(projectId, data.history ?? []);
// If Atlas finalized the PRD, save it to the project
if (data.prdContent) {
await savePrd(projectId, data.prdContent);
}
return NextResponse.json({
reply: data.reply,
sessionId,
prdContent: data.prdContent ?? null,
model: data.model ?? null,
});
} catch (err) {
console.error("[atlas-chat] Error:", err);
return NextResponse.json(
{ error: "Request timed out or failed. Please try again." },
{ status: 500 }
);
}
}
// ---------------------------------------------------------------------------
// DELETE — clear Atlas conversation for this project
// ---------------------------------------------------------------------------
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const sessionId = `atlas_${projectId}`;
try {
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${sessionId}`, { method: "DELETE" });
} catch { /* runner may be down */ }
try {
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
} catch { /* table may not exist yet */ }
return NextResponse.json({ cleared: true });
}

View File

@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
/**
* GET — returns surfaces[] and surfaceThemes{} for the project.
*/
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text
LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const data = rows[0].data ?? {};
return NextResponse.json({
surfaces: (data.surfaces ?? []) as string[],
surfaceThemes: (data.surfaceThemes ?? {}) as Record<string, string>,
});
} catch (err) {
console.error('[design-surfaces GET]', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
/**
* PATCH — two operations:
* { surfaces: string[] } — save the active surface list
* { surface: string, theme: string } — lock in a theme for one surface
*/
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Step 1: read current data — explicit ::text casts on every param
let rows: { data: Record<string, unknown> }[];
try {
rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text
LIMIT 1`,
[projectId, session.user.email]
);
} catch (selErr) {
console.error('[design-surfaces PATCH] SELECT failed:', selErr);
return NextResponse.json({ error: 'Internal error (select)' }, { status: 500 });
}
if (rows.length === 0) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
const current = rows[0].data ?? {};
const body = await req.json() as
| { surfaces: string[] }
| { surface: string; theme: string };
let updated: Record<string, unknown>;
if ('surfaces' in body) {
updated = { ...current, surfaces: body.surfaces, updatedAt: new Date().toISOString() };
} else if ('surface' in body && 'theme' in body) {
const existing = (current.surfaceThemes ?? {}) as Record<string, string>;
updated = {
...current,
surfaceThemes: { ...existing, [body.surface]: body.theme },
updatedAt: new Date().toISOString(),
};
} else {
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
}
// Step 2: write back — explicit ::text cast on id param, ::jsonb on data param
try {
await query(
`UPDATE fs_projects SET data = $1::jsonb WHERE id = $2::text`,
[JSON.stringify(updated), projectId]
);
} catch (updErr) {
console.error('[design-surfaces PATCH] UPDATE failed:', updErr);
return NextResponse.json({ error: 'Internal error (update)' }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err) {
console.error('[design-surfaces PATCH]', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,108 @@
/**
* GET /api/projects/[projectId]/file?path=apps/admin
*
* Returns directory listing or file content from the project's Gitea repo.
* Response for directory: { type: "dir", items: [{ name, path, type }] }
* Response for file: { type: "file", content: string, encoding: "utf8" | "base64" }
*/
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
async function giteaGet(path: string) {
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
next: { revalidate: 10 },
});
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
return res.json();
}
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico',
'woff', 'woff2', 'ttf', 'eot',
'zip', 'tar', 'gz', 'pdf',
]);
function isBinary(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return BINARY_EXTENSIONS.has(ext);
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const filePath = searchParams.get('path') ?? '';
// Verify ownership + get giteaRepo
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const giteaRepo = rows[0].data?.giteaRepo as string | undefined;
if (!giteaRepo) {
return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 });
}
const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : '';
const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`;
const data = await giteaGet(apiPath);
// Directory listing
if (Array.isArray(data)) {
const items = data
.map((item: { name: string; path: string; type: string; size?: number }) => ({
name: item.name,
path: item.path,
type: item.type, // "file" | "dir" | "symlink"
size: item.size,
}))
.sort((a, b) => {
// Dirs first
if (a.type === 'dir' && b.type !== 'dir') return -1;
if (a.type !== 'dir' && b.type === 'dir') return 1;
return a.name.localeCompare(b.name);
});
return NextResponse.json({ type: 'dir', items });
}
// Single file
const item = data as { name: string; content?: string; encoding?: string; size?: number };
if (isBinary(item.name)) {
return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' });
}
// Gitea returns base64-encoded content
const raw = item.content ?? '';
let content: string;
try {
content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8');
} catch {
content = raw;
}
return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name });
} catch (err) {
console.error('[file API]', err);
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
}
}

View File

@@ -0,0 +1,139 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export const maxDuration = 120;
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
async function callGemini(prompt: string): Promise<string> {
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3, maxOutputTokens: 8000 },
}),
});
const data = await res.json();
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json() as {
analysisResult?: Record<string, unknown>;
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
};
// Verify ownership
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const current = rows[0].data ?? {};
const projectName = (current.productName as string) || (current.name as string) || 'the product';
const { analysisResult, sourceData } = body;
const prompt = `You are a senior DevOps and platform migration architect. Generate a comprehensive, phased migration plan in Markdown for migrating an existing product into a new infrastructure (VIBN — a self-hosted PaaS).
Product: ${projectName}
Repo: ${sourceData?.repoUrl || 'Not provided'}
Live URL: ${sourceData?.liveUrl || 'Not provided'}
Current hosting: ${sourceData?.hosting || 'Unknown'}
Architecture audit summary:
${analysisResult?.summary || 'No audit data provided.'}
Detected components:
${JSON.stringify(analysisResult?.rows || [], null, 2).slice(0, 3000)}
Generate a complete migration plan with exactly these 4 phases:
# ${projectName} — Migration Plan
## Overview
Brief 2-3 sentence description of the migration approach and guiding principle (non-destructive duplication).
## Phase 1: Mirror
Set up parallel infrastructure on VIBN without touching production.
- [ ] Clone repository to VIBN Gitea
- [ ] Configure Coolify application
- [ ] Set up identical database schema
- [ ] Configure environment variables
- [ ] Verify build passes
## Phase 2: Validate
Run both systems in parallel and compare outputs.
- [ ] Route 5% of traffic to new infrastructure (or test internally)
- [ ] Compare API responses between old and new
- [ ] Run full end-to-end test suite
- [ ] Validate data sync between databases
- [ ] Sign off on performance benchmarks
## Phase 3: Cutover
Redirect production traffic to the new infrastructure.
- [ ] Update DNS records to point to VIBN load balancer
- [ ] Monitor error rates and latency for 24h
- [ ] Validate all integrations (auth, payments, third-party APIs)
- [ ] Keep old infrastructure on standby for 7 days
## Phase 4: Decommission
Remove old infrastructure after successful validation period.
- [ ] Confirm all data has been migrated
- [ ] Archive old repository access
- [ ] Terminate old hosting resources
- [ ] Update all internal documentation
## Risk Register
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Database migration failure | Medium | High | Full backup before any migration step |
| DNS propagation delay | Low | Medium | Use low TTL before cutover |
| Third-party integration breakage | Medium | High | Test all webhooks and OAuth in Phase 2 |
## Rollback Plan
At any phase, revert by: pointing DNS back to original infrastructure. Data written during parallel run must be synced back manually. Old infrastructure MUST remain live until Phase 4 completes.
---
Write a thorough, specific plan. Use real details from the audit where available. Every checklist item should be actionable. Return only the Markdown document.`;
const migrationPlan = await callGemini(prompt);
// Save to project
const updated = {
...current,
migrationPlan,
creationStage: 'plan',
updatedAt: new Date().toISOString(),
};
await query(
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
[projectId, JSON.stringify(updated)]
);
return NextResponse.json({ migrationPlan });
} catch (err) {
console.error('[generate-migration-plan]', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
import { createKnowledgeItem } from '@/lib/server/knowledge';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
@@ -32,9 +34,12 @@ export async function POST(
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
}
const adminDb = getAdminDb();
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
if (!projectSnap.exists) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const projectRows = await query(`SELECT id FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId]);
if (projectRows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export async function GET(
request: Request,
@@ -7,75 +9,30 @@ export async function GET(
) {
try {
const { projectId } = await params;
// Authentication (skip in development if no auth header)
const authHeader = request.headers.get('Authorization');
const isDevelopment = process.env.NODE_ENV === 'development';
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = getAdminAuth();
const decoded = await auth.verifyIdToken(token);
if (!decoded?.uid) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Fetch knowledge items from Firestore
console.log('[API /knowledge/items] Fetching items for project:', projectId);
let items = [];
try {
const adminDb = getAdminDb();
const knowledgeSnapshot = await adminDb
.collection('knowledge')
.where('projectId', '==', projectId)
.orderBy('createdAt', 'desc')
.limit(100)
.get();
console.log('[API /knowledge/items] Found', knowledgeSnapshot.size, 'items');
items = knowledgeSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
title: data.title || data.content?.substring(0, 50) || 'Untitled',
sourceType: data.sourceType,
content: data.content,
sourceMeta: data.sourceMeta,
createdAt: data.createdAt?.toDate?.()?.toISOString() || data.createdAt,
updatedAt: data.updatedAt?.toDate?.()?.toISOString() || data.updatedAt,
};
});
} catch (firestoreError) {
console.error('[API /knowledge/items] Firestore query failed:', firestoreError);
console.error('[API /knowledge/items] This is likely due to missing Firebase Admin credentials or Firestore not being set up');
// Return empty array instead of failing - the UI will show "No chats yet" and "No images yet"
items = [];
}
return NextResponse.json({
success: true,
items,
count: items.length,
});
} catch (error) {
console.error('[API /knowledge/items] Error fetching knowledge items:', error);
console.error('[API /knowledge/items] Error stack:', error instanceof Error ? error.stack : 'No stack trace');
return NextResponse.json(
{
error: 'Failed to fetch knowledge items',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
const rows = await query<{ id: string; data: any; created_at: string; updated_at: string }>(
`SELECT id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100`,
[projectId]
);
const items = rows.map((row) => ({
id: row.id,
title: row.data?.title || row.data?.content?.substring(0, 50) || 'Untitled',
sourceType: row.data?.sourceType,
content: row.data?.content,
sourceMeta: row.data?.sourceMeta,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
return NextResponse.json({ success: true, items, count: items.length });
} catch (error) {
console.error('[API /knowledge/items] Error:', error);
return NextResponse.json({ success: true, items: [], count: 0 });
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
async function assertOwnership(projectId: string, email: string): Promise<boolean> {
const rows = await query(
`SELECT p.id FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, email]
);
return rows.length > 0;
}
// GET /api/projects/[projectId]/knowledge
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { projectId } = await params;
if (!(await assertOwnership(projectId, session.user.email))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const rows = await query<{ id: string; data: any; updated_at: string }>(
`SELECT id, data, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC`,
[projectId]
);
return NextResponse.json({
items: rows.map((r) => ({ id: r.id, ...r.data, updatedAt: r.updated_at })),
});
}
// POST /api/projects/[projectId]/knowledge — add or update an item
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { projectId } = await params;
if (!(await assertOwnership(projectId, session.user.email))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const { key, type, value } = await req.json();
if (!key || !value) {
return NextResponse.json({ error: "key and value are required" }, { status: 400 });
}
const itemType = type ?? "note";
const data = JSON.stringify({ key, type: itemType, value, source: "user" });
// Upsert by key
const existing = await query<{ id: string }>(
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
[projectId, key]
);
if (existing.length > 0) {
await query(
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[data, existing[0].id]
);
return NextResponse.json({ id: existing[0].id, key, type: itemType, value, updated: true });
} else {
const rows = await query<{ id: string }>(
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb) RETURNING id`,
[projectId, data]
);
return NextResponse.json({ id: rows[0].id, key, type: itemType, value, created: true });
}
}
// DELETE /api/projects/[projectId]/knowledge?id=xxx
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { projectId } = await params;
if (!(await assertOwnership(projectId, session.user.email))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
await query(
`DELETE FROM fs_knowledge_items WHERE id = $1 AND project_id = $2`,
[id, projectId]
);
return NextResponse.json({ deleted: true });
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
import { listApplications, CoolifyApplication } from '@/lib/coolify';
const GITEA_BASE = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
export interface PreviewApp {
name: string;
url: string | null;
status: string;
coolifyUuid: string | null;
gitRepo: string | null;
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 1. Load project — get the Gitea repo name
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const data = rows[0].data ?? {};
const giteaRepo = data.giteaRepo as string | undefined; // e.g. "mark/scout-ai-engine"
if (!giteaRepo) {
return NextResponse.json({ apps: [], message: 'No Gitea repo linked to this project' });
}
// 2. Build the possible Gitea remote URLs for this repo (with and without .git)
const repoBase = `${GITEA_BASE}/${giteaRepo}`;
const repoUrls = new Set([repoBase, `${repoBase}.git`]);
// 3. Fetch all Coolify applications and match by git_repository
let coolifyApps: CoolifyApplication[] = [];
try {
coolifyApps = await listApplications();
} catch (err) {
console.error('[preview-url] Coolify fetch failed:', err);
// Fall back to stored data
}
const matched = coolifyApps.filter(app =>
app.git_repository && repoUrls.has(app.git_repository.replace(/\/$/, ''))
);
if (matched.length > 0) {
const apps: PreviewApp[] = matched.map(app => ({
name: app.name,
url: app.fqdn
? (app.fqdn.startsWith('http') ? app.fqdn : `https://${app.fqdn}`)
: null,
status: app.status ?? 'unknown',
coolifyUuid: app.uuid,
gitRepo: app.git_repository ?? null,
}));
return NextResponse.json({ apps, source: 'coolify' });
}
// 4. Fallback: use whatever URL was stored by the Coolify webhook
const lastDeployment = (data as any).contextSnapshot?.lastDeployment ?? null;
if (lastDeployment?.url) {
const apps: PreviewApp[] = [{
name: giteaRepo.split('/').pop() ?? 'app',
url: lastDeployment.url.startsWith('http') ? lastDeployment.url : `https://${lastDeployment.url}`,
status: lastDeployment.status === 'finished' ? 'running' : lastDeployment.status ?? 'unknown',
coolifyUuid: lastDeployment.applicationUuid ?? null,
gitRepo: giteaRepo,
}];
return NextResponse.json({ apps, source: 'webhook' });
}
return NextResponse.json({
apps: [],
message: `No Coolify app found for repo: ${giteaRepo}`,
giteaRepo,
});
}

Some files were not shown because too many files have changed in this diff Show More