Compare commits

..

80 Commits

Author SHA1 Message Date
d6c87a052e feat(domains): P5.1 — OpenSRS registration + Cloud DNS + Coolify attach
Adds end-to-end custom apex domain support: workspace-scoped
registration via OpenSRS (Tucows), authoritative DNS via Google
Cloud DNS, and one-call attach that wires registrar nameservers,
DNS records, and Coolify app routing in a single transactional
flow.

Schema (additive, idempotent — run /api/admin/migrate after deploy)
  - vibn_workspaces.dns_provider TEXT DEFAULT 'cloud_dns'
      Per-workspace DNS backend choice. Future: 'cira_dzone' for
      strict CA-only residency on .ca.
  - vibn_domains
      One row per registered/intended apex. Tracks status
      (pending|active|failed|expired), registrar order id, encrypted
      registrar manage-user creds (AES-256-GCM, VIBN_SECRETS_KEY),
      period, dates, dns_provider/zone_id/nameservers, and a
      created_by audit field.
  - vibn_domain_events
      Append-only lifecycle audit (register.attempt/success/fail,
      attach.success, ns.update, lock.toggle, etc).
  - vibn_billing_ledger
      Workspace-scoped money ledger (CAD by default) with
      ref_type/ref_id back to the originating row.

OpenSRS XML client (lib/opensrs.ts)
  - Mode-gated host/key (OPENSRS_MODE=test → horizon sandbox,
    rejectUnauthorized:false; live → rr-n1-tor, strict TLS).
  - MD5 double-hash signature.
  - Pure Node https module (no undici dep).
  - Verbs: lookupDomain, getDomainPrice, checkDomain, registerDomain,
    updateDomainNameservers, setDomainLock, getResellerBalance.
  - TLD policy: minPeriodFor() bumps .ai to 2y; CPR/legalType
    plumbed through for .ca; registrations default to UNLOCKED so
    immediate NS updates succeed without a lock toggle.

DNS provider abstraction (lib/dns/{provider,cloud-dns}.ts)
  - DnsProvider interface (createZone/getZone/setRecords/deleteZone)
    so the workspace residency knob can swap backends later.
  - cloudDnsProvider implementation against Google Cloud DNS using
    the existing vibn-workspace-provisioner SA (roles/dns.admin).
  - Idempotent zone creation, additions+deletions diff for rrsets.

Shared GCP auth (lib/gcp-auth.ts)
  - Single getGcpAccessToken() helper used by Cloud DNS today and
    future GCP integrations. Prefers GOOGLE_SERVICE_ACCOUNT_KEY_B64,
    falls back to ADC.

Workspace-scoped helpers (lib/domains.ts)
  - listDomainsForWorkspace, getDomainForWorkspace, createDomainIntent,
    markDomainRegistered, markDomainFailed, markDomainAttached,
    recordDomainEvent, recordLedgerEntry.

Attach orchestrator (lib/domain-attach.ts)
  Single function attachDomain() reused by REST + MCP. For one
  apex it:
    1. Resolves target → Coolify app uuid OR raw IP OR CNAME.
    2. Ensures Cloud DNS managed zone exists.
    3. Writes A / CNAME records (apex + requested subdomains).
    4. Updates registrar nameservers, with auto unlock-retry-relock
       fallback for TLDs that reject NS changes while locked.
    5. PATCHes the Coolify application's domain list so Traefik
       routes the new hostname.
    6. Persists dns_provider/zone_id/nameservers and emits an
       attach.success domain_event.
  AttachError carries a stable .tag + http status so the caller
  can map registrar/dns/coolify failures cleanly.

REST endpoints
  - POST   /api/workspaces/[slug]/domains/search
  - GET    /api/workspaces/[slug]/domains
  - POST   /api/workspaces/[slug]/domains
  - GET    /api/workspaces/[slug]/domains/[domain]
  - POST   /api/workspaces/[slug]/domains/[domain]/attach
  All routes go through requireWorkspacePrincipal (session OR
  Authorization: Bearer vibn_sk_...). Register is idempotent:
  re-issuing for an existing intent re-attempts at OpenSRS without
  duplicating the row or charging twice.

MCP bridge (app/api/mcp/route.ts → version 2.2.0)
  Adds five tools backed by the same library code:
    - domains.search    (batch availability + pricing)
    - domains.list      (workspace-owned)
    - domains.get       (single + recent events)
    - domains.register  (idempotent OpenSRS register)
    - domains.attach    (full Cloud DNS + registrar + Coolify)

Sandbox smoke tests (scripts/smoke-opensrs-*.ts)
  Standalone Node scripts validating each new opensrs.ts call against
  horizon.opensrs.net: balance + lookup + check, TLD policy
  (.ca/.ai/.io/.com), full register flow, NS update with systemdns
  nameservers, and the lock/unlock toggle that backs the attach
  fallback path.

Post-deploy checklist
  1. POST https://vibnai.com/api/admin/migrate
       -H "x-admin-secret: $ADMIN_MIGRATE_SECRET"
  2. Set OPENSRS_* env vars on the vibn-frontend Coolify app
     (RESELLER_USERNAME, API_KEY_LIVE, API_KEY_TEST, HOST_LIVE,
     HOST_TEST, PORT, MODE). Without them, only domains.list/get
     work; search/register/attach return 500.
  3. GCP_PROJECT_ID is read from env or defaults to master-ai-484822.
  4. Live attach end-to-end against a real apex is queued as a
     follow-up — sandbox path is fully proven.

Not in this commit (deliberate)
  - The 100+ unrelated in-flight files (mvp-setup wizard, justine
    homepage rework, BuildLivePlanPanel, etc) — kept local to keep
    blast radius minimal.

Made-with: Cursor
2026-04-21 16:30:39 -07:00
de1cd96ec2 fix(auth): classify services by service_type, not name heuristics
Coolify exposes the template slug on `service_type`; the list endpoint
returns only summaries, so the auth list handler now fetches each
service individually to classify it reliably. Users can name auth
services anything (e.g. "my-login") and they still show up as auth
providers.

Made-with: Cursor
2026-04-21 12:37:21 -07:00
62c52747f5 fix(coolify): list project databases across per-flavor arrays
GET /projects/{uuid}/{envName} returns databases split into
postgresqls/mysqls/mariadbs/mongodbs/redis/keydbs/dragonflies/clickhouses
sibling arrays instead of a unified `databases` list. Combine all of
them in listDatabasesInProject. Also normalize setApplicationDomains
to prepend https:// on bare hostnames (Coolify validates as URL).

Made-with: Cursor
2026-04-21 12:30:36 -07:00
b1670c7035 fix(coolify): tenant-match by environment_id via project envs
The v4 /applications, /databases, /services list endpoints don't
return project_uuid; authoritative link is environment_id. Replace
the explicit-only tenant check (which was rejecting every resource)
with a check that:
  - trusts explicit project_uuid if present
  - else looks up project envs via GET /projects/{uuid} and matches
    environment_id
Also switch the project list helpers to use GET /projects/{uuid}/{env}
so listing returns only the resources scoped to the workspace's
project + environments.

Made-with: Cursor
2026-04-21 12:23:09 -07:00
eacec74701 fix(coolify): use /deploy?uuid=... endpoint (Coolify v4)
Made-with: Cursor
2026-04-21 12:07:12 -07:00
a591c55fc4 fix(coolify): use correct /deployments/applications/{uuid} endpoint
Made-with: Cursor
2026-04-21 12:05:51 -07:00
0797717bc1 Phase 4: AI-driven app/database/auth lifecycle
Workspace-owned deploy infra so AI agents can create and destroy
Coolify resources without ever touching the root admin token.

  vibn_workspaces
    + coolify_server_uuid, coolify_destination_uuid
    + coolify_environment_name (default "production")
    + coolify_private_key_uuid, gitea_bot_ssh_key_id

  ensureWorkspaceProvisioned
    + generates an ed25519 keypair per workspace
    + pushes pubkey to the Gitea bot user (read/write scoped by team)
    + registers privkey in Coolify as a reusable deploy key

  New endpoints under /api/workspaces/[slug]/
    apps/                POST (private-deploy-key from Gitea repo)
    apps/[uuid]          PATCH, DELETE?confirm=<name>
    apps/[uuid]/domains  GET, PATCH (policy: *.{ws}.vibnai.com only)
    databases/           GET, POST (8 types incl. postgres, clickhouse, dragonfly)
    databases/[uuid]     GET, PATCH, DELETE?confirm=<name>
    auth/                GET, POST (Pocketbase, Authentik, Keycloak, Pocket-ID, Logto, Supertokens)
    auth/[uuid]          DELETE?confirm=<name>

  MCP (/api/mcp) gains 15 new tools that mirror the REST surface and
  enforce the same workspace tenancy + delete-confirm guard.

  Safety: destructive ops require ?confirm=<exact-resource-name>; volumes
  are kept by default (pass delete_volumes=true to drop).

Made-with: Cursor
2026-04-21 12:04:59 -07:00
b51fb6da21 fix(workspaces): don't short-circuit provisioning when bot token is missing
ensureWorkspaceProvisioned was bailing out as soon as provision_status=='ready',
even if gitea_bot_token_encrypted had been cleared (e.g. after a manual rotation).
Check every sub-resource is present before skipping.

Made-with: Cursor
2026-04-21 11:20:58 -07:00
14835e2e0a Revert "fix(gitea-bot): add write:organization scope so bot can create repos"
This reverts commit 6f79a88abd.

Made-with: Cursor
2026-04-21 11:12:20 -07:00
6f79a88abd fix(gitea-bot): add write:organization scope so bot can create repos
Without this the bot PAT 403s on POST /orgs/{org}/repos, which is
the single most important operation — creating new project repos
inside the workspace's Gitea org.

Made-with: Cursor
2026-04-21 11:05:55 -07:00
d9d3514647 fix(gitea-bot): mint PAT via Basic auth, not Sudo header
Gitea's POST /users/{name}/tokens is explicitly Basic-auth only;
neither the admin token nor Sudo header is accepted. Keep the random
password we generate at createUser time and pass it straight into
createAccessTokenFor as Basic auth.

For bots that already exist from a half-failed previous provision
run, reset their password via PATCH /admin/users/{name} so we can
Basic-auth as them and mint a fresh token.

Made-with: Cursor
2026-04-21 10:58:25 -07:00
b9511601bc feat(ai-access): per-workspace Gitea bot + tenant-safe Coolify proxy + MCP
Ship Phases 1–3 of the multi-tenant AI access plan so an AI agent can
act on a Vibn workspace with one bearer token and zero admin reach.

Phase 1 — Gitea bot per workspace
- Add gitea_bot_username / gitea_bot_user_id / gitea_bot_token_encrypted
  columns to vibn_workspaces (migrate route).
- New lib/auth/secret-box.ts (AES-256-GCM, VIBN_SECRETS_KEY) for PAT at rest.
- Extend lib/gitea.ts with createUser, createAccessTokenFor (Sudo PAT),
  createOrgTeam, addOrgTeamMember, ensureOrgTeamMembership.
- ensureWorkspaceProvisioned now mints a vibn-bot-<slug> user, adds it to
  a Writers team (write perms only) on the workspace's org, and stores
  its PAT encrypted.
- GET /api/workspaces/[slug]/gitea-credentials returns a workspace-scoped
  bot PAT + clone URL template; session or vibn_sk_ bearer auth.

Phase 2 — Tenant-safe Coolify proxy + real MCP
- lib/coolify.ts: projectUuidOf, listApplicationsInProject,
  getApplicationInProject, TenantError, env CRUD, deployments list.
- Workspace-scoped REST endpoints (all filtered by coolify_project_uuid):
  GET/POST /api/workspaces/[slug]/apps/[uuid](/deploy|/envs|/deployments),
  GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs.
- Full rewrite of /api/mcp off legacy Firebase onto Postgres vibn_sk_
  keys, exposing workspace.describe, gitea.credentials, projects.*,
  apps.* (list/get/deploy/deployments, envs.list/upsert/delete).

Phase 3 — Settings UI AI bundle
- GET /api/workspaces/[slug]/bootstrap.sh: curl|sh installer that writes
  .cursor/rules, .cursor/mcp.json and appends VIBN_* to .env.local.
  Embeds the caller's vibn_sk_ token when invoked with bearer auth.
- WorkspaceKeysPanel: single AiAccessBundleCard with system-prompt block,
  one-line bootstrap, Reveal-bot-PAT button, collapsible manual-setup
  fallback. Minted-key modal also shows the bootstrap one-liner.

Ops prerequisites:
  - Set VIBN_SECRETS_KEY (>=16 chars) on the frontend.
  - Run /api/admin/migrate to add the three bot columns.
  - GITEA_API_TOKEN must be a site-admin token (needed for admin/users
    + Sudo PAT mint); otherwise provision_status lands on 'partial'.

Made-with: Cursor
2026-04-21 10:49:17 -07:00
6ccfdee65f fix(settings): use NextAuth session instead of undefined Firebase auth
The settings page imported `auth` from `@/lib/firebase/config` and called
`auth.currentUser` inside an unguarded `useEffect`. Since the app runs on
PostgreSQL + NextAuth (Firebase isn't configured), `auth` was `undefined`
and the uncaught TypeError crashed React's commit, leaving the page blank
behind the Next.js dev error overlay. The WorkspaceKeysPanel never got a
chance to mount even though `/api/workspaces` was returning fine.

Swap to `useSession()` from `next-auth/react` to read display name + email
from the existing NextAuth session. Drop the dead fetch to
`/api/workspace/{slug}/settings`, which was never implemented.

Made-with: Cursor
2026-04-20 21:19:05 -07:00
0bdf598984 fix(workspace-panel): resolve workspace via /api/workspaces, not URL slug
The panel was fetching /api/workspaces/{urlSlug} where {urlSlug}
is whatever is in the `[workspace]` dynamic segment (e.g.
"mark-account"). That slug has nothing to do with vibn_workspaces.slug,
which is derived from the user's email — so the fetch 404'd, the
component showed "Loading workspace…" forever, and minting/revoking
would target a non-existent workspace.

Now:
- GET /api/workspaces lazy-creates a workspace row if the signed-in
  user has none (migration path for accounts created before the
  signIn hook was added).
- WorkspaceKeysPanel discovers the user's actual workspace from that
  list and uses *its* slug for all subsequent calls (details, keys,
  provisioning, revocation).
- Empty / error states render a proper card with a retry button
  instead of a bare "Workspace not found." line.

Made-with: Cursor
2026-04-20 20:43:46 -07:00
4b2289adfa chore(webhook): verify gitea→coolify auto-deploy
Made-with: Cursor
2026-04-20 20:30:55 -07:00
5fbba46a3d fix(build): add missing lib/auth/session-server.ts
Coolify build for acb63a2 failed with:
  Module not found: Can't resolve '@/lib/auth/session-server'
in app/api/projects/create/route.ts, app/api/workspaces/route.ts,
and lib/auth/workspace-auth.ts.

The file existed locally but was never committed in any prior
turn, so the previous build still worked (no consumers) and the
new workspaces feature could not. Adding it now unblocks the
deploy.

Made-with: Cursor
2026-04-20 18:11:12 -07:00
acb63a2a5a feat(workspaces): per-account tenancy + AI access keys + Cursor integration
Adds logical multi-tenancy on top of Coolify + Gitea so every Vibn
account gets its own isolated tenant boundary, and exposes that
boundary to AI agents (Cursor, Claude Code, scripts) through
per-workspace bearer tokens.

Schema (additive, idempotent — run /api/admin/migrate once after deploy)
  - vibn_workspaces: slug, name, owner, coolify_project_uuid,
    coolify_team_id (reserved for when Coolify ships POST /teams),
    gitea_org, provision_status
  - vibn_workspace_members: room for multi-user workspaces later
  - vibn_workspace_api_keys: sha256-hashed bearer tokens
  - fs_projects.vibn_workspace_id: nullable FK linking projects
    to their workspace

Provisioning
  - On first sign-in, ensureWorkspaceForUser() inserts the row
    (no network calls — keeps signin fast).
  - On first project create, ensureWorkspaceProvisioned() lazily
    creates a Coolify Project (vibn-ws-{slug}) and a Gitea org
    (vibn-{slug}). Failures are recorded on the row, not thrown,
    and POST /api/workspaces/{slug}/provision retries.

Auth surface
  - lib/auth/workspace-auth.ts: requireWorkspacePrincipal() accepts
    either a NextAuth session or "Authorization: Bearer vibn_sk_...".
    The bearer key is hard-pinned to one workspace — it cannot
    reach any other tenant.
  - mintWorkspaceApiKey / listWorkspaceApiKeys / revokeWorkspaceApiKey

Routes
  - GET    /api/workspaces                         list
  - GET    /api/workspaces/[slug]                  details
  - POST   /api/workspaces/[slug]/provision        retry provisioning
  - GET    /api/workspaces/[slug]/keys             list keys
  - POST   /api/workspaces/[slug]/keys             mint key (token shown once)
  - DELETE /api/workspaces/[slug]/keys/[keyId]     revoke

UI
  - components/workspace/WorkspaceKeysPanel.tsx: identity card,
    keys CRUD with one-time secret reveal, and a "Connect Cursor"
    block with copy/download for:
      .cursor/rules/vibn-workspace.mdc — rule telling the agent
        about the API + workspace IDs + house rules
      ~/.cursor/mcp.json — MCP server registration with key
        embedded (server URL is /api/mcp; HTTP MCP route lands next)
      .env.local — VIBN_API_KEY + smoke-test curl
  - Slotted into existing /[workspace]/settings between Workspace
    and Notifications cards (no other layout changes).

projects/create
  - Resolves the user's workspace (creating + provisioning lazily).
  - Repos go under workspace.gitea_org (falls back to GITEA_ADMIN_USER
    for backwards compat).
  - Coolify services are created inside workspace.coolify_project_uuid
    (renamed {slug}-{appName} to stay unique within the namespace) —
    no more per-Vibn-project Coolify Project sprawl.
  - Stamps vibn_workspace_id on fs_projects.

lib/gitea
  - createOrg, getOrg, addOrgOwner, getUser
  - createRepo now routes /orgs/{owner}/repos when owner != admin

Also includes prior-turn auth hardening that was already in
authOptions.ts (CredentialsProvider for dev-local, isLocalNextAuth
cookie config) bundled in to keep the auth layer in one consistent
state.

.env.example
  - Documents GITEA_API_URL / GITEA_API_TOKEN / GITEA_ADMIN_USER /
    GITEA_WEBHOOK_SECRET and COOLIFY_URL / COOLIFY_API_TOKEN /
    COOLIFY_SERVER_UUID, with the canonical hostnames
    (git.vibnai.com, coolify.vibnai.com).

Post-deploy
  - Run once: curl -X POST https://vibnai.com/api/admin/migrate \\
      -H "x-admin-secret: \$ADMIN_MIGRATE_SECRET"
  - Existing users get a workspace row on next sign-in.
  - Existing fs_projects keep working (legacy gitea owner + their
    own per-project Coolify Projects); new projects use the
    workspace-scoped path.

Not in this commit (follow-ups)
  - Wiring requireWorkspacePrincipal into the rest of /api/projects/*
    so API keys can drive existing routes
  - HTTP MCP server at /api/mcp (the mcp.json snippet already
    points at the right URL — no client re-setup when it lands)
  - Backfill script to assign legacy fs_projects to a workspace

Made-with: Cursor
2026-04-20 17:17:12 -07:00
ccc6cc1da5 feat(justine): isolate design system — verbatim CSS + (justine) route group
- Add app/styles/justine/01-homepage.css: rules from 01_homepage.html scoped to [data-justine]
- Replace app/(marketing) with app/(justine): layout wraps data-justine + Plus Jakarta
- JustineHomePage/Nav/Footer: original class names (btn-ink, hero-grid, …) + inline styles from HTML
- Remove app/justine-marketing.css; move /features /pricing /privacy /terms under (justine)

Made-with: Cursor
2026-04-02 12:05:33 -07:00
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
130 changed files with 17669 additions and 2314 deletions

61
.env.example Normal file
View File

@@ -0,0 +1,61 @@
# Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets.
# --- Postgres: local `next dev` (Coolify internal hostnames do NOT work on your laptop) ---
# npm run db:local:up then npm run db:local:push with:
# DATABASE_URL=postgresql://vibn:vibn@localhost:5433/vibn
# POSTGRES_URL=postgresql://vibn:vibn@localhost:5433/vibn
# --- Postgres: production / Coolify (from Coolify UI, reachable from where the app runs) ---
# Coolify: open the Postgres service → expose/publish a host port → use SERVER_IP:HOST_PORT (not internal UUID host).
# From repo root, master-ai/.coolify.env with COOLIFY_URL + COOLIFY_API_TOKEN: npm run db:sync:coolify
# Example shape: postgresql://USER:PASSWORD@34.19.250.135:YOUR_PUBLISHED_PORT/vibn
# External/cloud: set DB_SSL=true if the DB requires TLS.
DATABASE_URL=
POSTGRES_URL=
# --- Public URL of this Next app (OAuth callbacks, runner callbacks) ---
# Local Google OAuth (must match the host/port you open in the browser):
# NEXTAUTH_URL=http://localhost:3000
# Google Cloud Console → OAuth client → Authorized redirect URIs (exact):
# http://localhost:3000/api/auth/callback/google
# If you use 127.0.0.1 or another port, use that consistently everywhere.
# Prisma adapter needs Postgres + tables: set DATABASE_URL then run: npx prisma db push
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=
# --- Gitea (git.vibnai.com) — admin token used to create per-workspace orgs/repos ---
# Token must have admin scope to create orgs. Per-workspace repos are created
# under "vibn-{workspace-slug}" orgs; legacy projects remain under GITEA_ADMIN_USER.
GITEA_API_URL=https://git.vibnai.com
GITEA_API_TOKEN=
GITEA_ADMIN_USER=mark
GITEA_WEBHOOK_SECRET=
# --- Coolify (coolify.vibnai.com) — admin token used to create per-workspace Projects ---
# Each Vibn workspace gets one Coolify Project (named "vibn-ws-{slug}") that
# acts as the tenant boundary. All apps + DBs for that workspace live there.
COOLIFY_URL=https://coolify.vibnai.com
COOLIFY_API_TOKEN=
COOLIFY_SERVER_UUID=jws4g4cgssss4cw48s488woc
# --- Google OAuth / Gemini (see .google.env locally) ---
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# --- Local dev: skip Google (next dev only) ---
# NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL=you@example.com
# Skip NextAuth session for API + project UI (same email must own rows in fs_users)
# NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH=true
# Optional: require password for dev-local provider (omit to allow localhost Host only)
# DEV_LOCAL_AUTH_SECRET=
# Optional display name for the dev user row
# DEV_LOCAL_AUTH_NAME=Local dev

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

@@ -44,6 +44,9 @@ 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.

47
app/(justine)/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import { homepage } from "@/marketing/content/homepage";
import { JustineNav } from "@/marketing/components/justine/JustineNav";
import { JustineFooter } from "@/marketing/components/justine/JustineFooter";
import "../styles/justine/01-homepage.css";
const justineJakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-justine-jakarta",
display: "swap",
});
export const metadata: Metadata = {
title: homepage.meta.title,
description: homepage.meta.description,
openGraph: {
title: homepage.meta.title,
description: homepage.meta.description,
url: "https://www.vibnai.com",
siteName: "VIBN",
type: "website",
},
twitter: {
card: "summary_large_image",
title: homepage.meta.title,
description: homepage.meta.description,
},
};
export default function JustineLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
data-justine
className={`${justineJakarta.variable} flex min-h-screen flex-col`}
>
<JustineNav />
<main>{children}</main>
<JustineFooter />
</div>
);
}

5
app/(justine)/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { JustineHomePage } from "@/marketing/components/justine/JustineHomePage";
export default function LandingPage() {
return <JustineHomePage />;
}

View File

@@ -1,94 +0,0 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
import type { Metadata } from "next";
import { homepage } from "@/marketing/content/homepage";
import { Footer } from "@/marketing/components";
export const metadata: Metadata = {
title: homepage.meta.title,
description: homepage.meta.description,
openGraph: {
title: homepage.meta.title,
description: homepage.meta.description,
url: "https://www.vibnai.com",
siteName: "VIBN",
type: "website",
},
twitter: {
card: "summary_large_image",
title: homepage.meta.title,
description: homepage.meta.description,
},
};
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen flex-col">
{/* Navigation */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center">
<div className="flex gap-6 md:gap-10">
<Link href="/" className="flex items-center space-x-2">
<img
src="/vibn-black-circle-logo.png"
alt="Vib'n"
className="h-8 w-8"
/>
<span className="text-xl font-bold">Vib&apos;n</span>
</Link>
</div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<nav className="flex items-center space-x-6">
<Link
href="/#features"
className="text-sm font-medium transition-colors hover:text-primary"
>
Features
</Link>
<Link
href="/#how-it-works"
className="text-sm font-medium transition-colors hover:text-primary"
>
How It Works
</Link>
<Link
href="/#pricing"
className="text-sm font-medium transition-colors hover:text-primary"
>
Pricing
</Link>
<Link
href="https://github.com/MawkOne/viben"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium transition-colors hover:text-primary"
>
GitHub
</Link>
</nav>
<div className="flex items-center space-x-4">
<Link href="/auth">
<Button variant="ghost" size="sm">
Sign In
</Button>
</Link>
<Link href="/auth">
<Button size="sm">Get Started</Button>
</Link>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 w-full">{children}</main>
<Footer />
</div>
);
}

View File

@@ -1,26 +0,0 @@
import {
Hero,
EmotionalHook,
WhoItsFor,
Transformation,
Features,
HowItWorks,
Pricing,
CTA,
} from "@/marketing/components";
export default function LandingPage() {
return (
<div className="flex flex-col">
<Hero />
<EmotionalHook />
<WhoItsFor />
<Transformation />
<Features />
<HowItWorks />
<Pricing />
<CTA />
</div>
);
}

View File

@@ -32,7 +32,7 @@ function typeColor(t: string) {
const FILTERS = [
{ id: "all", label: "All" },
{ id: "atlas", label: "Atlas" },
{ id: "atlas", label: "Vibn" },
{ id: "build", label: "Builds" },
{ id: "deploy", label: "Deploys" },
{ id: "user", label: "You" },
@@ -58,10 +58,10 @@ export default function ActivityPage() {
return (
<div
className="vibn-enter"
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "Outfit, sans-serif" }}
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<h1 style={{
fontFamily: "Newsreader, serif", fontSize: "1.9rem",
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
}}>
Activity
@@ -81,7 +81,7 @@ export default function ActivityPage() {
background: filter === f.id ? "#1a1a1a" : "#fff",
color: filter === f.id ? "#fff" : "#6b6560",
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
cursor: "pointer", fontFamily: "Outfit, sans-serif",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
{f.label}

View File

@@ -0,0 +1,133 @@
"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 });
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}>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 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 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

@@ -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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,7 @@ export default function DeploymentPage() {
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
@@ -77,11 +77,11 @@ export default function DeploymentPage() {
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 560 }}>
<h3 style={{
fontFamily: "Newsreader, serif", fontSize: "1.2rem",
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
}}>
Deployment
@@ -103,7 +103,7 @@ export default function DeploymentPage() {
<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: "Outfit, sans-serif" }}>
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>
@@ -117,7 +117,7 @@ export default function DeploymentPage() {
</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: "Outfit, sans-serif" }}>
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>
@@ -130,7 +130,7 @@ export default function DeploymentPage() {
<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: "Outfit, sans-serif" }}>
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>
@@ -140,7 +140,7 @@ export default function DeploymentPage() {
<div style={{ padding: "18px 0", textAlign: "center" }}>
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
{!hasPRD
? "Complete your PRD with Atlas first, then build and deploy."
? "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."}
@@ -166,7 +166,7 @@ export default function DeploymentPage() {
<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: "Outfit, sans-serif", opacity: connecting ? 0.6 : 1 }}
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>

View File

@@ -1,6 +1,7 @@
"use client";
import { use, useState, useEffect } from "react";
import { use, useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import {
SCAFFOLD_REGISTRY, THEME_REGISTRY,
@@ -360,7 +361,7 @@ const LIBRARY_STYLE_OPTIONS: Record<string, LibraryStyleOptions> = {
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>{label}</span>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{label}</span>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{children}
</div>
@@ -382,7 +383,7 @@ function OptionChip({
display: "flex", alignItems: "center", gap: 5,
padding: multi ? "4px 9px" : "4px 11px",
borderRadius: 5, border: "1px solid",
fontSize: "0.72rem", fontFamily: "Outfit", cursor: "pointer",
fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
transition: "all 0.1s",
borderColor: active ? "#1a1a1a" : "#e0dcd4",
background: active ? "#1a1a1a" : "#fff",
@@ -410,7 +411,7 @@ function ModeToggle({ value, onChange }: { value: string; onChange: (v: "dark" |
key={m}
onClick={() => onChange(id)}
style={{
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "Outfit",
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: "pointer", fontWeight: active ? 600 : 400,
background: active ? "#1a1a1a" : "transparent",
color: active ? "#fff" : "#8a8478",
@@ -621,7 +622,7 @@ function SurfaceSection({
{ScaffoldComponent
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} config={designConfig} />
: (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "Outfit" }}>
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Select a library below to preview
</div>
)
@@ -646,7 +647,7 @@ function SurfaceSection({
style={{
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
fontFamily: "Outfit", cursor: "pointer", transition: "opacity 0.15s",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", transition: "opacity 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
@@ -659,7 +660,7 @@ function SurfaceSection({
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
color: previewId && !saving ? "#fff" : "#b5b0a6",
fontSize: "0.76rem", fontWeight: 600, fontFamily: "Outfit",
fontSize: "0.76rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: !previewId || saving ? "not-allowed" : "pointer",
transition: "opacity 0.15s",
}}
@@ -669,7 +670,7 @@ function SurfaceSection({
)}
{activeTheme && (
<a href={activeTheme.url} target="_blank" rel="noopener noreferrer"
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "Outfit", flexShrink: 0 }}
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>Docs </a>
@@ -678,7 +679,7 @@ function SurfaceSection({
{/* 2. Library */}
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Library</span>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Library</span>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{surface.themes.map(theme => {
const isActive = theme.id === previewId;
@@ -692,7 +693,7 @@ function SurfaceSection({
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "4px 11px", borderRadius: 5, border: "1px solid",
fontSize: "0.72rem", fontFamily: "Outfit", cursor: dimmed ? "not-allowed" : "pointer",
fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: dimmed ? "not-allowed" : "pointer",
transition: "all 0.1s", opacity: dimmed ? 0.35 : 1,
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
background: isActive ? "#1a1a1a" : "#fff",
@@ -719,7 +720,7 @@ function SurfaceSection({
{/* 4. Colour */}
{availableColorThemes.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Colour</span>
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
{availableColorThemes.map(ct => (
<button
@@ -739,7 +740,7 @@ function SurfaceSection({
))}
</div>
{activeColorTheme && (
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "Outfit" }}>{activeColorTheme.label}</span>
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{activeColorTheme.label}</span>
)}
</div>
)}
@@ -800,7 +801,7 @@ function SurfaceSection({
{/* Colour swatches when locked (read-only) */}
{isLocked && availableColorThemes.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Colour</span>
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
{availableColorThemes.map(ct => (
<button key={ct.id} title={ct.label} disabled
@@ -862,8 +863,8 @@ function SurfacePicker({
};
return (
<div style={{ padding: "28px 32px", fontFamily: "Outfit, sans-serif" }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
<div style={{ padding: "28px 32px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
Design surfaces
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
@@ -897,7 +898,7 @@ function SurfacePicker({
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
background: isSelected ? "#1a1a1a08" : "#fff",
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
cursor: "pointer", transition: "all 0.12s", fontFamily: "Outfit",
cursor: "pointer", transition: "all 0.12s", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
position: "relative",
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
@@ -936,7 +937,7 @@ function SurfacePicker({
padding: "9px 20px", borderRadius: 7, border: "none",
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
fontSize: "0.82rem", fontWeight: 600, fontFamily: "Outfit",
fontSize: "0.82rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
transition: "opacity 0.15s",
}}
@@ -946,7 +947,7 @@ function SurfacePicker({
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`}
</button>
{selected.size === 0 && (
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit" }}>
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Select at least one surface to continue
</p>
)}
@@ -958,8 +959,9 @@ function SurfacePicker({
// Page
// ---------------------------------------------------------------------------
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
const { projectId } = use(params);
function DesignPageInner({ projectId }: { projectId: string }) {
const searchParams = useSearchParams();
const requestedSurface = searchParams.get("surface");
const [surfaces, setSurfaces] = useState<string[]>([]);
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
@@ -979,7 +981,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
setSurfaces(loaded);
setSurfaceThemes(d.surfaceThemes ?? {});
setSelectedThemes(d.surfaceThemes ?? {});
if (loaded.length > 0) setActiveSurfaceId(loaded[0]);
// Honour ?surface= param if valid, otherwise default to first
const initial = requestedSurface && loaded.includes(requestedSurface)
? requestedSurface
: loaded[0] ?? null;
setActiveSurfaceId(initial);
return loaded;
});
@@ -1050,7 +1056,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
@@ -1066,7 +1072,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
const lockedCount = Object.keys(surfaceThemes).length;
return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif" }}>
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Left nav */}
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
@@ -1089,7 +1095,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
cursor: "pointer", transition: "all 0.12s", position: "relative",
fontFamily: "Outfit",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
@@ -1107,11 +1113,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "Outfit", marginBottom: 6 }}> All locked</p>
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 6 }}> All locked</p>
)}
<button
onClick={() => setSurfaces([])}
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "Outfit", padding: 0 }}
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", padding: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
>
@@ -1137,3 +1143,12 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
</div>
);
}
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
const { projectId } = use(params);
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>}>
<DesignPageInner projectId={projectId} />
</Suspense>
);
}

View File

@@ -11,10 +11,10 @@ export default function GrowPage() {
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 560 }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
<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 }}>

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

@@ -11,10 +11,10 @@ export default function InsightsPage() {
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 560 }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
<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 }}>

View File

@@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
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;
productName: string;
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;
};
analysisResult?: Record<string, unknown>;
migrationPlan?: string;
}
export default function ProjectOverviewPage() {
const params = useParams();
const projectId = params.projectId as string;
const { status: authStatus } = useSession();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
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 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 (!project) {
return (
<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 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 (
<FreshIdeaMain
projectId={projectId}
projectName={projectName}
/>
);
}

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

@@ -122,7 +122,7 @@ export default function ProjectSettingsPage() {
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
<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>
);
@@ -131,10 +131,10 @@ export default function ProjectSettingsPage() {
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 480 }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
<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 }}>

View File

@@ -1,475 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
interface App {
name: string;
type: string;
description: string;
tech: string[];
screens: string[];
}
interface Package {
name: string;
description: string;
}
interface Infra {
name: string;
reason: string;
}
interface Integration {
name: string;
required: boolean;
notes: string;
}
interface Architecture {
productName: string;
productType: string;
summary: string;
apps: App[];
packages: Package[];
infrastructure: Infra[];
integrations: Integration[];
designSurfaces: string[];
riskNotes: string[];
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.12em", textTransform: "uppercase",
marginBottom: 10, marginTop: 28,
}}>
{children}
</div>
);
}
function AppCard({ app }: { app: App }) {
const [open, setOpen] = useState(false);
const icons: Record<string, string> = {
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
mobile: "📱", worker: "⚙️", engine: "🎯",
};
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 8, overflow: "hidden",
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: "100%", textAlign: "left", background: "none", border: "none",
cursor: "pointer", padding: "14px 18px",
display: "flex", alignItems: "center", gap: 12,
fontFamily: "Outfit, sans-serif",
}}
>
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
apps/{app.name}
</span>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#6b6560", background: "#f0ece4",
padding: "2px 7px", borderRadius: 4,
}}>
{app.type}
</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
{app.description}
</div>
</div>
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
</button>
{open && (
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
{app.tech.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{app.tech.map((t, i) => (
<span key={i} style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#4a4640", background: "#f6f4f0",
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
}}>{t}</span>
))}
</div>
</div>
)}
{app.screens.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{app.screens.map((s, i) => (
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: "#c5c0b8" }}></span> {s}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
export default function BuildPage() {
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 [architectureConfirmed, setArchitectureConfirmed] = useState(false);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/architecture`)
.then(r => r.json())
.then(d => {
setPrd(d.prd);
setArchitecture(d.architecture ?? null);
setLoading(false);
})
.catch(() => setLoading(false));
// Also check confirmed flag
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
.catch(() => {});
}, [projectId]);
const handleGenerate = async (force = false) => {
setGenerating(true);
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ forceRegenerate: force }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error || "Generation failed");
setArchitecture(d.architecture);
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setGenerating(false);
}
};
const handleConfirm = async () => {
setConfirming(true);
try {
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
setArchitectureConfirmed(true);
} catch { /* swallow */ } finally {
setConfirming(false);
}
};
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
// No PRD yet
if (!prd) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 360 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
Complete your PRD first
</h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
</p>
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Continue with Atlas
</Link>
</div>
</div>
);
}
// PRD exists but no architecture yet — prompt to generate
if (!architecture) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 440 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
</h3>
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
The AI will read your PRD and recommend the technical structure apps, services, database, and integrations. You'll review it before anything gets built.
</p>
{error && (
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
{error}
</div>
)}
<button
onClick={() => handleGenerate(false)}
disabled={generating}
style={{
padding: "11px 28px", borderRadius: 8,
background: generating ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
transition: "background 0.15s",
}}
>
{generating ? "Analysing PRD…" : "Generate architecture →"}
</button>
{generating && (
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
This takes about 1530 seconds
</p>
)}
</div>
</div>
);
}
// Architecture loaded — show full review UI
return (
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
<div>
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Architecture
</h2>
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
{architecture.productType}
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{architectureConfirmed && (
<span style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
}}>
✓ Confirmed
</span>
)}
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "6px 14px", borderRadius: 6,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
fontFamily: "Outfit, sans-serif",
}}
>
{generating ? "Regenerating…" : "Regenerate"}
</button>
</div>
</div>
{/* Summary */}
<div style={{
marginTop: 18, padding: "16px 20px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
}}>
{architecture.summary}
</div>
{/* Apps */}
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
{/* Packages */}
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{architecture.packages.map((pkg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "12px 16px",
}}>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
packages/{pkg.name}
</div>
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
{pkg.description}
</div>
</div>
))}
</div>
{/* Infrastructure */}
{architecture.infrastructure.length > 0 && (
<>
<SectionLabel>Infrastructure</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.infrastructure.map((infra, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#3d5afe", background: "#3d5afe0d",
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
flexShrink: 0, marginTop: 1,
}}>
{infra.name}
</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
{infra.reason}
</span>
</div>
))}
</div>
</>
)}
{/* Integrations */}
{architecture.integrations.length > 0 && (
<>
<SectionLabel>External integrations</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.integrations.map((intg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: intg.required ? "#9a7b3a" : "#8a8478",
background: intg.required ? "#d4a04a12" : "#f6f4f0",
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
}}>
{intg.required ? "required" : "optional"}
</span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
</div>
</div>
))}
</div>
</>
)}
{/* Risk notes */}
{architecture.riskNotes.length > 0 && (
<>
<SectionLabel>Architecture risks</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.riskNotes.map((risk, i) => (
<div key={i} style={{
background: "#fff8f0", border: "1px solid #ffe0b2",
borderRadius: 8, padding: "10px 16px",
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
display: "flex", gap: 8,
}}>
<span>⚠️</span><span>{risk}</span>
</div>
))}
</div>
</>
)}
{/* Confirm section */}
<div style={{
marginTop: 32, padding: "20px 24px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
borderLeft: "3px solid #1a1a1a",
}}>
{architectureConfirmed ? (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
✓ Architecture confirmed
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
</p>
<Link href={`/${workspace}/project/${projectId}/design`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Choose your design →
</Link>
</div>
) : (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
Does this look right?
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
Review the structure above. You can regenerate if something's off, or confirm to move to design.
You can always come back and adjust before the build starts nothing gets scaffolded yet.
</p>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handleConfirm}
disabled={confirming}
style={{
padding: "9px 22px", borderRadius: 7,
background: confirming ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
}}
>
{confirming ? "Confirming…" : "Confirm architecture →"}
</button>
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "9px 18px", borderRadius: 7,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.78rem", color: "#8a8478",
fontFamily: "Outfit, sans-serif", cursor: "pointer",
}}
>
Regenerate
</button>
</div>
</div>
)}
</div>
<div style={{ height: 40 }} />
</div>
);
}

View File

@@ -11,6 +11,7 @@ interface ProjectData {
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
async function getProjectData(projectId: string): Promise<ProjectData> {
@@ -31,6 +32,7 @@ async function getProjectData(projectId: string): Promise<ProjectData> {
createdAt: created_at,
updatedAt: updated_at,
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
creationMode: data?.creationMode ?? "fresh",
};
}
} catch (error) {
@@ -62,6 +64,7 @@ export default async function ProjectLayout({
createdAt={project.createdAt}
updatedAt={project.updatedAt}
featureCount={project.featureCount}
creationMode={project.creationMode}
>
{children}
</ProjectShell>

View File

@@ -1,120 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { AtlasChat } from "@/components/AtlasChat";
import { OrchestratorChat } from "@/components/OrchestratorChat";
import { Loader2 } from "lucide-react";
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
const [show, setShow] = useState(false);
const url = typeof window !== "undefined"
? `${window.location.origin}/${workspace}/project/${projectId}/overview`
: "";
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`;
return (
<div style={{ position: "relative" }}>
<button
onClick={() => setShow(s => !s)}
title="Open on your phone"
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "6px 12px", borderRadius: 7,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.72rem", fontFamily: "Outfit, sans-serif",
color: "#8a8478", cursor: "pointer",
transition: "border-color 0.12s",
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
>
📱 Open on phone
</button>
{show && (
<div style={{
position: "absolute", top: "calc(100% + 8px)", right: 0,
background: "#fff", borderRadius: 12,
border: "1px solid #e8e4dc",
boxShadow: "0 8px 24px #1a1a1a12",
padding: "16px", zIndex: 50,
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
minWidth: 220,
}}>
<img src={qrSrc} alt="QR code" width={180} height={180} style={{ borderRadius: 8 }} />
<p style={{ fontSize: "0.72rem", color: "#8a8478", textAlign: "center", margin: 0, fontFamily: "Outfit, sans-serif" }}>
Scan to open Atlas on your phone
</p>
<p style={{ fontSize: "0.65rem", color: "#b5b0a6", textAlign: "center", margin: 0, fontFamily: "IBM Plex Mono, monospace", wordBreak: "break-all" }}>
{url}
</p>
<button onClick={() => setShow(false)} style={{ fontSize: "0.68rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer" }}>
Close
</button>
</div>
)}
</div>
);
}
interface Project {
id: string;
productName: string;
stage?: "discovery" | "architecture" | "building" | "active";
}
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);
useEffect(() => {
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 style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
</div>
);
}
if (!project) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
Project not found.
</div>
);
}
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* Desktop-only: Open on phone button */}
<style>{`@media (max-width: 768px) { .vibn-phone-btn { display: none !important; } }`}</style>
<div className="vibn-phone-btn" style={{
position: "absolute", top: 14, right: 248,
zIndex: 20,
}}>
<MobileQRButton projectId={projectId} workspace={workspace} />
</div>
<AtlasChat
projectId={projectId}
projectName={project.productName}
/>
</div>
);
}

View File

@@ -1,246 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } 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: null },
{ 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: "Outfit, 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>
);
}
export default function PRDPage() {
const params = useParams();
const projectId = params.projectId as string;
const [prd, setPrd] = useState<string | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
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);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
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: "Outfit, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
return (
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
{prd ? (
/* ── Finalized PRD view ── */
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "Newsreader, 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: "Outfit, sans-serif",
}}>
{prd}
</div>
</div>
) : (
/* ── 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 Atlas`
: "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 Atlas saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -59,7 +59,7 @@ function StatusTag({ status }: { status?: string }) {
display: "inline-flex", alignItems: "center", gap: 5,
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
color, background: bg, fontFamily: "Outfit, sans-serif",
color, background: bg, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
<StatusDot status={status} /> {label}
</span>
@@ -76,6 +76,7 @@ export default function ProjectsPage() {
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 {
@@ -134,13 +135,13 @@ export default function ProjectsPage() {
return (
<div
className="vibn-enter"
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "Outfit, sans-serif" }}
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: "Newsreader, serif", fontSize: "1.9rem",
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
lineHeight: 1.15, marginBottom: 4,
}}>
@@ -158,7 +159,7 @@ export default function ProjectsPage() {
background: "#1a1a1a", color: "#fff",
border: "1px solid #1a1a1a",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
>
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
@@ -188,15 +189,17 @@ export default function ProjectsPage() {
width: "100%", display: "flex", alignItems: "center",
padding: "18px 22px", borderRadius: 10,
background: "#fff", border: "1px solid #e8e4dc",
cursor: "pointer", fontFamily: "Outfit, sans-serif",
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";
}}
@@ -209,7 +212,7 @@ export default function ProjectsPage() {
flexShrink: 0,
}}>
<span style={{
fontFamily: "Newsreader, serif",
fontFamily: "var(--font-lora), ui-serif, serif",
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
}}>
{p.productName[0]?.toUpperCase() ?? "P"}
@@ -247,19 +250,19 @@ export default function ProjectsPage() {
</div>
</div>
{/* Delete (hover) */}
{/* Delete (visible on row hover) */}
<button
onClick={(e) => { e.preventDefault(); setProjectToDelete(p); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
style={{
marginLeft: 16, padding: "5px 8px", borderRadius: 6,
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
border: "none", background: "transparent",
color: "#b5b0a6", cursor: "pointer",
opacity: 0, transition: "opacity 0.15s",
fontFamily: "Outfit, sans-serif",
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,
}}
className="delete-btn"
onMouseEnter={(e) => e.currentTarget.style.color = "#d32f2f"}
onMouseLeave={(e) => e.currentTarget.style.color = "#b5b0a6"}
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
title="Delete project"
>
<Trash2 style={{ width: 14, height: 14 }} />
@@ -275,7 +278,7 @@ export default function ProjectsPage() {
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
padding: "22px", borderRadius: 10,
background: "transparent", border: "1px dashed #d0ccc4",
cursor: "pointer", fontFamily: "Outfit, sans-serif",
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`,
@@ -292,11 +295,11 @@ export default function ProjectsPage() {
{/* Empty state */}
{!loading && projects.length === 0 && (
<div style={{ textAlign: "center", paddingTop: 64 }}>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
<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 Atlas what you want to build and it will figure out the rest.
Tell Vibn what you want to build and it will figure out the rest.
</p>
<button
onClick={() => setShowNew(true)}
@@ -304,7 +307,7 @@ export default function ProjectsPage() {
padding: "10px 22px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
border: "none", fontSize: "0.84rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
>
Create your first project

View File

@@ -5,10 +5,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
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 { useSession } from 'next-auth/react';
import { toast } from 'sonner';
import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { WorkspaceKeysPanel } from '@/components/workspace/WorkspaceKeysPanel';
import {
AlertDialog,
AlertDialogAction,
@@ -31,6 +32,7 @@ export default function SettingsPage() {
const params = useParams();
const router = useRouter();
const workspace = params.workspace as string;
const { data: session, status } = useSession();
const [settings, setSettings] = useState<WorkspaceSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -38,51 +40,19 @@ export default function SettingsPage() {
const [email, setEmail] = useState('');
useEffect(() => {
loadSettings();
loadUserProfile();
}, []);
const loadSettings = async () => {
try {
const user = auth.currentUser;
if (!user) return;
const token = await user.getIdToken();
const response = await fetch(`/api/workspace/${workspace}/settings`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setLoading(false);
}
};
const loadUserProfile = () => {
const user = auth.currentUser;
if (user) {
setDisplayName(user.displayName || '');
setEmail(user.email || '');
}
};
if (status === 'loading') return;
setDisplayName(session?.user?.name ?? '');
setEmail(session?.user?.email ?? '');
setLoading(false);
}, [session, status]);
const handleSaveProfile = async () => {
setSaving(true);
try {
const user = auth.currentUser;
if (!user) {
if (!session?.user) {
toast.error('Please sign in');
return;
}
// Update profile logic would go here
toast.success('Profile updated successfully');
} catch (error) {
console.error('Error saving profile:', error);
@@ -177,6 +147,9 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Workspace tenancy + AI access keys */}
<WorkspaceKeysPanel workspaceSlug={workspace} />
{/* Notifications */}
<Card>
<CardHeader>

View File

@@ -0,0 +1,307 @@
/**
* 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)
)`,
// ── Vibn workspaces (logical tenancy on top of Coolify) ──────────
// One workspace per Vibn account. Holds a Coolify Project UUID
// (the team boundary inside Coolify) and a Gitea org name.
`CREATE TABLE IF NOT EXISTS vibn_workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
coolify_project_uuid TEXT,
coolify_team_id INT,
gitea_org TEXT,
provision_status TEXT NOT NULL DEFAULT 'pending',
provision_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_workspaces_owner_idx ON vibn_workspaces (owner_user_id)`,
`CREATE TABLE IF NOT EXISTS vibn_workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS vibn_workspace_members_user_idx ON vibn_workspace_members (user_id)`,
`CREATE TABLE IF NOT EXISTS vibn_workspace_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes JSONB NOT NULL DEFAULT '["workspace:*"]'::jsonb,
created_by TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_workspace_api_keys_workspace_idx ON vibn_workspace_api_keys (workspace_id)`,
// Tag projects with the workspace they belong to (nullable until backfill).
// The pre-existing fs_projects.workspace TEXT column stays for the legacy slug;
// this new UUID FK is the canonical link.
`ALTER TABLE fs_projects ADD COLUMN IF NOT EXISTS vibn_workspace_id UUID REFERENCES vibn_workspaces(id) ON DELETE SET NULL`,
`CREATE INDEX IF NOT EXISTS fs_projects_vibn_workspace_idx ON fs_projects (vibn_workspace_id)`,
// ── Per-workspace Gitea bot user (for direct AI access) ──────────
// Each workspace gets its own Gitea user with a PAT scoped to the
// workspace's org, so AI agents can `git clone` / push directly
// without ever touching the root admin token.
//
// Token is encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
// Layout: iv(12) || ciphertext || authTag(16), base64-encoded.
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_username TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_user_id INT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_token_encrypted TEXT`,
// ── Phase 4: workspace-owned deploy infra ────────────────────────
// Lets AI agents create Coolify applications/databases/services
// against a Gitea repo the bot can read, routed to the right
// server and Docker destination, and exposed under the workspace's
// own subdomain namespace.
//
// coolify_server_uuid — which Coolify server the workspace deploys to
// coolify_destination_uuid — Docker network / destination on that server
// coolify_environment_name — Coolify environment (default "production")
// coolify_private_key_uuid — workspace-wide SSH deploy key (Coolify-side UUID)
// gitea_bot_ssh_key_id — Gitea key id for the matching public key (for rotation)
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_server_uuid TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_destination_uuid TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`,
// ── Phase 5.1: domains (OpenSRS) + DNS + billing ledger ──────────
//
// vibn_domains — owned domains + their registration lifecycle
// vibn_domain_events — audit trail (register, attach, renew, expire)
// vibn_billing_ledger — money in/out at the workspace level
//
// Reg credentials for a domain (OpenSRS manage-user password) are
// encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
//
// Workspace residency preference for DNS:
// dns_provider = 'cloud_dns' (default, public records)
// dns_provider = 'cira_dzone' (strict Canadian residency, future)
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS dns_provider TEXT NOT NULL DEFAULT 'cloud_dns'`,
`CREATE TABLE IF NOT EXISTS vibn_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
tld TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
registrar TEXT NOT NULL DEFAULT 'opensrs',
registrar_order_id TEXT,
registrar_username TEXT,
registrar_password_enc TEXT,
period_years INT NOT NULL DEFAULT 1,
whois_privacy BOOLEAN NOT NULL DEFAULT TRUE,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
registered_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
dns_provider TEXT,
dns_zone_id TEXT,
dns_nameservers JSONB,
last_reconciled_at TIMESTAMPTZ,
price_paid_cents INT,
price_currency TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (domain)
)`,
`CREATE INDEX IF NOT EXISTS vibn_domains_workspace_idx ON vibn_domains (workspace_id)`,
`CREATE INDEX IF NOT EXISTS vibn_domains_status_idx ON vibn_domains (status)`,
`CREATE INDEX IF NOT EXISTS vibn_domains_expires_idx ON vibn_domains (expires_at) WHERE expires_at IS NOT NULL`,
`CREATE TABLE IF NOT EXISTS vibn_domain_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES vibn_domains(id) ON DELETE CASCADE,
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_domain_events_domain_idx ON vibn_domain_events (domain_id, created_at DESC)`,
`CREATE TABLE IF NOT EXISTS vibn_billing_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
amount_cents INT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CAD',
ref_type TEXT,
ref_id TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_workspace_idx ON vibn_billing_ledger (workspace_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_ref_idx ON vibn_billing_ledger (ref_type, ref_id)`,
];
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 }
);
}

File diff suppressed because it is too large Load Diff

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,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

@@ -48,6 +48,7 @@ export async function GET(
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`);
@@ -55,11 +56,64 @@ export async function GET(
.filter((item) => item.type === 'dir')
.map(({ name, path }) => ({ name, path }));
} catch {
// Repo may not have an apps/ dir yet — return empty list gracefully
// 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 });
return NextResponse.json({ apps, designPackages, giteaRepo, isImport: !!(data.isImport || data.creationMode === 'migration') });
}
/**

View File

@@ -133,7 +133,7 @@ export async function POST(
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 Atlas and ask what the user is building. Do not acknowledge this as an internal trigger."
? "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,
@@ -146,7 +146,7 @@ export async function POST(
const text = await res.text();
console.error("[atlas-chat] Agent runner error:", text);
return NextResponse.json(
{ error: "Atlas is unavailable. Please try again." },
{ error: "Vibn is unavailable. Please try again." },
{ status: 502 }
);
}

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

@@ -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,
});
}

View File

@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { randomUUID } from 'crypto';
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
import { pushTurborepoScaffold } from '@/lib/scaffold';
import { createProject as createCoolifyProject, createMonorepoAppService } from '@/lib/coolify';
import { createMonorepoAppService } from '@/lib/coolify';
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
@@ -15,7 +15,7 @@ const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-s
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -53,6 +53,19 @@ export async function POST(request: Request) {
const firebaseUserId = users[0]!.id;
// Resolve (and lazily provision) the user's workspace. Provides:
// - vibnWorkspace.coolify_project_uuid → namespace for Coolify apps/DBs
// - vibnWorkspace.gitea_org → owner for Gitea repos
// If provisioning failed for either, we fall back to legacy admin
// identifiers so the project create still succeeds (with degraded isolation).
let vibnWorkspace = await getOrCreateProvisionedWorkspace({
userId: firebaseUserId,
email,
displayName: session.user.name ?? null,
});
const repoOwner = vibnWorkspace.gitea_org ?? GITEA_ADMIN_USER;
const body = await request.json();
const {
projectName,
@@ -66,6 +79,7 @@ export async function POST(request: Request) {
githubRepoId,
githubRepoUrl,
githubDefaultBranch,
githubToken,
} = body;
// Check slug uniqueness
@@ -96,14 +110,15 @@ export async function POST(request: Request) {
description: `${projectName} — managed by Vibn`,
private: true,
auto_init: false,
owner: repoOwner,
});
console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${repoName}`);
console.log(`[API] Gitea repo created: ${repoOwner}/${repoName}`);
} catch (createErr) {
const msg = createErr instanceof Error ? createErr.message : String(createErr);
// 409 = repo already exists — link to it instead of failing
if (msg.includes('409')) {
console.log(`[API] Gitea repo already exists, linking to ${GITEA_ADMIN_USER}/${repoName}`);
repo = await getRepo(GITEA_ADMIN_USER, repoName);
console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`);
repo = await getRepo(repoOwner, repoName);
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
} else {
throw createErr;
@@ -115,17 +130,37 @@ export async function POST(request: Request) {
giteaCloneUrl = repo.clone_url;
giteaSshUrl = repo.ssh_url;
// Push Turborepo monorepo scaffold as initial commit
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
// If a GitHub repo was provided, mirror it as-is.
// Otherwise push the default Turborepo scaffold.
if (githubRepoUrl) {
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
github_url: githubRepoUrl,
gitea_repo: `${repoOwner}/${repoName}`,
project_name: projectName,
github_token: githubToken || undefined,
}),
});
if (!mirrorRes.ok) {
const detail = await mirrorRes.text();
throw new Error(`GitHub mirror failed: ${detail}`);
}
console.log(`[API] GitHub repo mirrored to ${giteaRepo}`);
} else {
await pushTurborepoScaffold(repoOwner, repoName, slug, projectName);
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
}
// Register webhook — skip if one already points to this project
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
const existingHooks = await listWebhooks(GITEA_ADMIN_USER, repoName).catch(() => []);
const existingHooks = await listWebhooks(repoOwner, repoName).catch(() => []);
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
if (!alreadyHooked) {
const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
const hook = await createWebhook(repoOwner, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
giteaWebhookId = hook.id;
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
} else {
@@ -139,7 +174,7 @@ export async function POST(request: Request) {
}
// ──────────────────────────────────────────────
// 2. Provision Coolify project + per-app services
// 2. Provision per-app services under the workspace's Coolify Project
// ──────────────────────────────────────────────
const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com';
const appNames = ['product', 'website', 'admin', 'storybook'] as const;
@@ -147,32 +182,29 @@ export async function POST(request: Request) {
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null }));
if (giteaCloneUrl) {
try {
const coolifyProject = await createCoolifyProject(
projectName,
`Vibn project: ${projectName}`
);
// The workspace's Coolify Project IS our team boundary. All Vibn
// projects for a workspace share one Coolify Project namespace.
const coolifyProjectUuid: string | null = vibnWorkspace.coolify_project_uuid;
for (const app of provisionedApps) {
try {
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
const service = await createMonorepoAppService({
projectUuid: coolifyProject.uuid,
appName: app.name,
gitRepo: giteaCloneUrl,
domain,
});
app.coolifyServiceUuid = service.uuid;
app.domain = domain;
console.log(`[API] Coolify service created: ${app.name}${domain}`);
} catch (appErr) {
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
}
if (giteaCloneUrl && coolifyProjectUuid) {
for (const app of provisionedApps) {
try {
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
const service = await createMonorepoAppService({
projectUuid: coolifyProjectUuid,
appName: `${slug}-${app.name}`, // unique within the workspace's Coolify Project
gitRepo: giteaCloneUrl,
domain,
});
app.coolifyServiceUuid = service.uuid;
app.domain = domain;
console.log(`[API] Coolify service created: ${app.name}${domain}`);
} catch (appErr) {
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
}
} catch (coolifyErr) {
console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr);
}
} else if (!coolifyProjectUuid) {
console.warn('[API] Workspace has no Coolify Project UUID — skipped app provisioning. Run /api/workspaces/{slug}/provision to retry.');
}
// ──────────────────────────────────────────────
@@ -236,17 +268,23 @@ export async function POST(request: Request) {
theiaError,
// Context snapshot (kept fresh by webhooks)
contextSnapshot: null,
// Coolify project — one per VIBN project, scopes all app services + DBs
coolifyProjectUuid,
// Turborepo monorepo apps — each gets its own Coolify service
turboVersion: '2.3.3',
apps: provisionedApps,
// Import metadata
isImport: !!githubRepoUrl,
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
importAnalysisJobId: null as string | null,
createdAt: now,
updatedAt: now,
};
await query(`
INSERT INTO fs_projects (id, data, user_id, workspace, slug)
VALUES ($1, $2::jsonb, $3, $4, $5)
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]);
INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id)
VALUES ($1, $2::jsonb, $3, $4, $5, $6)
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]);
// Associate any unlinked sessions for this workspace path
if (workspacePath) {
@@ -262,7 +300,40 @@ export async function POST(request: Request) {
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
}
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped');
// ──────────────────────────────────────────────
// 5. If this is an import, trigger the analysis agent
// ──────────────────────────────────────────────
let analysisJobId: string | null = null;
if (githubRepoUrl && giteaRepo) {
try {
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
const jobRes = await fetch(`${agentRunnerUrl}/api/agent/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent: 'ImportAnalyzer',
task: `Analyze this imported codebase (originally from ${githubRepoUrl}) and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
repo: giteaRepo,
}),
});
if (jobRes.ok) {
const jobData = await jobRes.json() as { jobId?: string };
analysisJobId = jobData.jobId ?? null;
// Store the job ID on the project record
if (analysisJobId) {
await query(
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
[JSON.stringify(analysisJobId), projectId]
);
}
console.log(`[API] Import analysis job started: ${analysisJobId}`);
}
} catch (analysisErr) {
console.error('[API] Failed to start import analysis (non-fatal):', analysisErr);
}
}
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped', '| import:', !!githubRepoUrl);
return NextResponse.json({
success: true,
@@ -275,6 +346,8 @@ export async function POST(request: Request) {
giteaError: giteaError ?? undefined,
theiaWorkspaceUrl,
theiaError: theiaError ?? undefined,
isImport: !!githubRepoUrl,
analysisJobId: analysisJobId ?? undefined,
});
} catch (error) {
console.error('[POST /api/projects/create] Error:', error);

View File

@@ -0,0 +1,47 @@
/**
* POST /api/workspaces/[slug]/apps/[uuid]/deploy
*
* Trigger a deploy on a Coolify app. Guard: app must belong to this
* workspace's Coolify project before we forward the call.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
deployApplication,
getApplicationInProject,
TenantError,
} from '@/lib/coolify';
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
// Tenant check before any mutation.
await getApplicationInProject(uuid, ws.coolify_project_uuid);
const result = await deployApplication(uuid);
return NextResponse.json({
ok: true,
deploymentUuid: result.deployment_uuid,
appUuid: uuid,
});
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Deploy failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,41 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]/deployments
*
* Recent deployments for an app. Tenant-checked.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getApplicationInProject,
listApplicationDeployments,
TenantError,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
const deployments = await listApplicationDeployments(uuid);
return NextResponse.json({ deployments });
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,114 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]/domains — list current domains
* PATCH /api/workspaces/[slug]/apps/[uuid]/domains — replace domain set
*
* Body: { domains: string[] } — each must end with .{workspace}.vibnai.com.
* We enforce workspace-subdomain policy here to prevent AI-driven
* hijacking of other workspaces' subdomains.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getApplicationInProject,
setApplicationDomains,
TenantError,
} from '@/lib/coolify';
import {
isDomainUnderWorkspace,
parseDomainsString,
workspaceAppFqdn,
slugify,
} from '@/lib/naming';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
return NextResponse.json({
uuid: app.uuid,
name: app.name,
domains: parseDomainsString(app.domains ?? app.fqdn ?? ''),
workspaceDomainSuffix: `${ws.slug}.vibnai.com`,
});
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json({ error: 'App not found' }, { status: 404 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
let app;
try {
app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json({ error: 'App not found' }, { status: 404 });
}
let body: { domains?: string[] } = {};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const raw = Array.isArray(body.domains) ? body.domains : [];
if (raw.length === 0) {
return NextResponse.json({ error: '`domains` must be a non-empty array' }, { status: 400 });
}
// Normalize + policy-check.
const normalized: string[] = [];
for (const d of raw) {
if (typeof d !== 'string' || !d.trim()) continue;
const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
if (!isDomainUnderWorkspace(clean, ws.slug)) {
return NextResponse.json(
{
error: `Domain ${clean} is not allowed; must end with .${ws.slug}.vibnai.com`,
hint: `Use ${workspaceAppFqdn(ws.slug, slugify(app.name))}`,
},
{ status: 403 }
);
}
normalized.push(clean);
}
try {
await setApplicationDomains(uuid, normalized, { forceOverride: true });
return NextResponse.json({ ok: true, uuid, domains: normalized });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify domain update failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,154 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]/envs — list env vars
* PATCH /api/workspaces/[slug]/apps/[uuid]/envs — upsert one env var
* body: { key, value, is_preview?, is_build_time?, is_literal?, is_multiline? }
* DELETE /api/workspaces/[slug]/apps/[uuid]/envs?key=FOO — delete one env var
*
* Tenant boundary: the app must belong to the workspace's Coolify project.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
deleteApplicationEnv,
getApplicationInProject,
listApplicationEnvs,
TenantError,
upsertApplicationEnv,
} from '@/lib/coolify';
async function verify(request: Request, slug: string, uuid: string) {
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return { error: principal };
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return {
error: NextResponse.json(
{ error: 'Workspace has no Coolify project yet' },
{ status: 503 }
),
};
}
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) {
return { error: NextResponse.json({ error: err.message }, { status: 403 }) };
}
return {
error: NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
),
};
}
return { principal };
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
try {
const envs = await listApplicationEnvs(uuid);
// Redact values by default for API-key callers — they can re-fetch
// with ?reveal=true when they need the actual values (e.g. to copy
// a DATABASE_URL). Session callers always get full values.
const url = new URL(request.url);
const reveal =
check.principal.source === 'session' || url.searchParams.get('reveal') === 'true';
return NextResponse.json({
envs: envs.map(e => ({
key: e.key,
value: reveal ? e.value : maskValue(e.value),
isPreview: e.is_preview ?? false,
isBuildTime: e.is_build_time ?? false,
isLiteral: e.is_literal ?? false,
isMultiline: e.is_multiline ?? false,
})),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
let body: {
key?: string;
value?: string;
is_preview?: boolean;
is_build_time?: boolean;
is_literal?: boolean;
is_multiline?: boolean;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body.key || typeof body.value !== 'string') {
return NextResponse.json({ error: 'Fields "key" and "value" are required' }, { status: 400 });
}
try {
const env = await upsertApplicationEnv(uuid, {
key: body.key,
value: body.value,
is_preview: body.is_preview ?? false,
is_build_time: body.is_build_time ?? false,
is_literal: body.is_literal ?? false,
is_multiline: body.is_multiline ?? false,
});
return NextResponse.json({ ok: true, key: env.key });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
const key = new URL(request.url).searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'Query param "key" is required' }, { status: 400 });
}
try {
await deleteApplicationEnv(uuid, key);
return NextResponse.json({ ok: true, key });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
function maskValue(v: string): string {
if (!v) return '';
if (v.length <= 4) return '•'.repeat(v.length);
return `${v.slice(0, 2)}${'•'.repeat(Math.min(v.length - 4, 10))}${v.slice(-2)}`;
}

View File

@@ -0,0 +1,181 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid] — app details
* PATCH /api/workspaces/[slug]/apps/[uuid] — update fields (name/branch/build config)
* DELETE /api/workspaces/[slug]/apps/[uuid]?confirm=<name>
* — destroy app. Volumes kept by default.
*
* All verify the app's project uuid matches the workspace's before
* acting. DELETE additionally requires `?confirm=<exact-resource-name>`
* to prevent AI-driven accidents.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getApplicationInProject,
projectUuidOf,
TenantError,
updateApplication,
deleteApplication,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
return NextResponse.json({
uuid: app.uuid,
name: app.name,
status: app.status,
fqdn: app.fqdn ?? null,
domains: app.domains ?? null,
gitRepository: app.git_repository ?? null,
gitBranch: app.git_branch ?? null,
projectUuid: projectUuidOf(app),
});
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
// Verify tenancy first (400-style fail fast on cross-tenant access).
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json({ error: 'App not found' }, { status: 404 });
}
let body: Record<string, unknown> = {};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
// Whitelist which Coolify fields we expose to AI-level callers.
// Domains are managed via the dedicated /domains subroute.
const allowed = new Set([
'name',
'description',
'git_branch',
'build_pack',
'ports_exposes',
'install_command',
'build_command',
'start_command',
'base_directory',
'dockerfile_location',
'is_auto_deploy_enabled',
'is_force_https_enabled',
'static_image',
]);
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body)) {
if (allowed.has(k) && v !== undefined) patch[k] = v;
}
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 });
}
try {
await updateApplication(uuid, patch);
return NextResponse.json({ ok: true, uuid });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
// Resolve the app and verify tenancy.
let app;
try {
app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json({ error: 'App not found' }, { status: 404 });
}
// Require `?confirm=<exact app name>` to prevent accidental destroys.
const url = new URL(request.url);
const confirm = url.searchParams.get('confirm');
if (confirm !== app.name) {
return NextResponse.json(
{
error: 'Confirmation required',
hint: `Pass ?confirm=${app.name} to delete this app`,
},
{ status: 409 }
);
}
// Default: preserve volumes (user data). Caller can opt in.
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false';
const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false';
const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false';
try {
await deleteApplication(uuid, {
deleteConfigurations,
deleteVolumes,
deleteConnectedNetworks,
dockerCleanup,
});
return NextResponse.json({ ok: true, deleted: { uuid, name: app.name, volumesKept: !deleteVolumes } });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,232 @@
/**
* GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace
* POST /api/workspaces/[slug]/apps — create a new app from a Gitea repo
*
* Auth: session OR `Bearer vibn_sk_...`. The workspace's
* `coolify_project_uuid` acts as the tenant boundary — any app whose
* Coolify project uuid doesn't match is filtered out even if the
* token issuer accidentally had wider reach.
*
* POST body:
* {
* repo: string, // "my-api" or "{org}/my-api"
* branch?: string, // default: "main"
* name?: string, // default: derived from repo
* ports?: string, // default: "3000"
* buildPack?: "nixpacks"|"static"|"dockerfile"|"dockercompose"
* domain?: string, // default: {app}.{workspace}.vibnai.com
* envs?: Record<string,string>
* instantDeploy?: boolean, // default: true
* }
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
listApplicationsInProject,
projectUuidOf,
createPrivateDeployKeyApp,
upsertApplicationEnv,
getApplication,
deployApplication,
} from '@/lib/coolify';
import {
slugify,
workspaceAppFqdn,
toDomainsString,
isDomainUnderWorkspace,
giteaSshUrl,
} from '@/lib/naming';
import { getRepo } from '@/lib/gitea';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: 'Workspace has no Coolify project yet', apps: [] },
{ status: 503 }
);
}
try {
const apps = await listApplicationsInProject(ws.coolify_project_uuid);
return NextResponse.json({
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
apps: apps.map(a => ({
uuid: a.uuid,
name: a.name,
status: a.status,
fqdn: a.fqdn ?? null,
domains: a.domains ?? null,
gitRepository: a.git_repository ?? null,
gitBranch: a.git_branch ?? null,
projectUuid: projectUuidOf(a),
})),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (
!ws.coolify_project_uuid ||
!ws.coolify_private_key_uuid ||
!ws.gitea_org
) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' },
{ status: 503 }
);
}
type Body = {
repo?: string;
branch?: string;
name?: string;
ports?: string;
buildPack?: 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose';
domain?: string;
envs?: Record<string, string>;
instantDeploy?: boolean;
description?: string;
baseDirectory?: string;
installCommand?: string;
buildCommand?: string;
startCommand?: string;
dockerfileLocation?: string;
};
let body: Body = {};
try {
body = (await request.json()) as Body;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body.repo || typeof body.repo !== 'string') {
return NextResponse.json({ error: 'Missing "repo" field' }, { status: 400 });
}
// Accept either "repo-name" (assumed in workspace org) or "org/repo".
const parts = body.repo.replace(/\.git$/, '').split('/');
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
const repoName = parts.length === 2 ? parts[1] : parts[0];
if (repoOrg !== ws.gitea_org) {
return NextResponse.json(
{ error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` },
{ status: 403 }
);
}
if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
return NextResponse.json({ error: 'Invalid repo name' }, { status: 400 });
}
// Verify the repo actually exists in Gitea (fail fast).
const repo = await getRepo(repoOrg, repoName);
if (!repo) {
return NextResponse.json(
{ error: `Repo ${repoOrg}/${repoName} not found in Gitea` },
{ status: 404 }
);
}
const appName = slugify(body.name ?? repoName);
const fqdn = body.domain
? body.domain.replace(/^https?:\/\//, '')
: workspaceAppFqdn(ws.slug, appName);
if (!isDomainUnderWorkspace(fqdn, ws.slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` },
{ status: 403 }
);
}
try {
const created = await createPrivateDeployKeyApp({
projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
privateKeyUuid: ws.coolify_private_key_uuid,
gitRepository: giteaSshUrl(repoOrg, repoName),
gitBranch: body.branch ?? repo.default_branch ?? 'main',
portsExposes: body.ports ?? '3000',
buildPack: body.buildPack ?? 'nixpacks',
name: appName,
description: body.description ?? `AI-created from ${repoOrg}/${repoName}`,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
isForceHttpsEnabled: true,
// We defer the first deploy until envs are attached so they
// show up in the initial build.
instantDeploy: false,
baseDirectory: body.baseDirectory,
installCommand: body.installCommand,
buildCommand: body.buildCommand,
startCommand: body.startCommand,
dockerfileLocation: body.dockerfileLocation,
});
// Attach env vars (best-effort — don't fail the whole create on one bad key).
if (body.envs && typeof body.envs === 'object') {
for (const [key, value] of Object.entries(body.envs)) {
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
try {
await upsertApplicationEnv(created.uuid, { key, value });
} catch (e) {
console.warn('[apps.POST] upsert env failed', key, e);
}
}
}
// Now kick off the first deploy (unless the caller opted out).
let deploymentUuid: string | null = null;
if (body.instantDeploy !== false) {
try {
const dep = await deployApplication(created.uuid);
deploymentUuid = dep.deployment_uuid ?? null;
} catch (e) {
console.warn('[apps.POST] initial deploy failed', e);
}
}
// Return a hydrated object (status / urls) for the UI.
const app = await getApplication(created.uuid);
return NextResponse.json(
{
uuid: app.uuid,
name: app.name,
status: app.status,
domain: fqdn,
url: `https://${fqdn}`,
gitRepository: app.git_repository ?? null,
gitBranch: app.git_branch ?? null,
deploymentUuid,
},
{ status: 201 }
);
} catch (err) {
return NextResponse.json(
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,92 @@
/**
* GET /api/workspaces/[slug]/auth/[uuid] — provider details
* DELETE /api/workspaces/[slug]/auth/[uuid]?confirm=<name>
* Volumes KEPT by default (don't blow away user accounts).
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getServiceInProject,
deleteService,
projectUuidOf,
TenantError,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
const svc = await getServiceInProject(uuid, ws.coolify_project_uuid);
return NextResponse.json({
uuid: svc.uuid,
name: svc.name,
status: svc.status ?? null,
projectUuid: projectUuidOf(svc),
});
} catch (err) {
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
let svc;
try {
svc = await getServiceInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
}
const url = new URL(request.url);
const confirm = url.searchParams.get('confirm');
if (confirm !== svc.name) {
return NextResponse.json(
{ error: 'Confirmation required', hint: `Pass ?confirm=${svc.name}` },
{ status: 409 }
);
}
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
try {
await deleteService(uuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
return NextResponse.json({
ok: true,
deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes },
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,180 @@
/**
* Workspace authentication providers.
*
* GET /api/workspaces/[slug]/auth — list auth-provider services
* POST /api/workspaces/[slug]/auth — provision one of the vetted providers
*
* AI-callers can only create providers from an allowlist — we deliberately
* skip the rest of Coolify's ~300 one-click templates so this endpoint
* stays focused on "auth for my app". The allowlist:
*
* pocketbase — lightweight (SQLite-backed) auth + data
* authentik — feature-rich self-hosted IDP
* keycloak — industry-standard OIDC/SAML
* keycloak-with-postgres
* pocket-id — passkey-first OIDC
* pocket-id-with-postgresql
* logto — dev-first IDP
* supertokens-with-postgresql — session/auth backend
*
* (Zitadel is not on Coolify's service catalog — callers that ask for
* it get a descriptive 400 so the AI knows to pick a supported one.)
*
* POST body:
* { provider: "pocketbase", name?: "auth" }
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
listServicesInProject,
createService,
getService,
projectUuidOf,
} from '@/lib/coolify';
import { slugify } from '@/lib/naming';
/**
* Vetted auth-provider service ids. Keys are what callers pass as
* `provider`; values are the Coolify service-template slugs.
*/
const AUTH_PROVIDERS: Record<string, string> = {
pocketbase: 'pocketbase',
authentik: 'authentik',
keycloak: 'keycloak',
'keycloak-with-postgres': 'keycloak-with-postgres',
'pocket-id': 'pocket-id',
'pocket-id-with-postgresql': 'pocket-id-with-postgresql',
logto: 'logto',
'supertokens-with-postgresql': 'supertokens-with-postgresql',
};
/** Anything in this set is Coolify-supported but not an auth provider (used for filtering the list view). */
const AUTH_PROVIDER_SLUGS = new Set(Object.values(AUTH_PROVIDERS));
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: 'Workspace has no Coolify project yet', providers: [] },
{ status: 503 }
);
}
try {
const all = await listServicesInProject(ws.coolify_project_uuid);
// Coolify's list endpoint only returns summaries (no service_type) so
// we fetch each service individually to classify it by template slug.
// This is O(n) in services-per-workspace — acceptable at single-digit
// scales — and avoids name-based heuristics that break on custom names.
const detailed = await Promise.all(all.map(s => getService(s.uuid).catch(() => s)));
return NextResponse.json({
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
providers: detailed
.filter(s => {
const t = resolveProviderSlug(s);
return !!t && AUTH_PROVIDER_SLUGS.has(t);
})
.map(s => ({
uuid: s.uuid,
name: s.name,
status: s.status ?? null,
provider: resolveProviderSlug(s),
projectUuid: projectUuidOf(s),
})),
allowedProviders: Object.keys(AUTH_PROVIDERS),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
let body: { provider?: string; name?: string; description?: string; instantDeploy?: boolean } = {};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const providerKey = (body.provider ?? '').toLowerCase().trim();
const coolifyType = AUTH_PROVIDERS[providerKey];
if (!coolifyType) {
return NextResponse.json(
{
error: `Unsupported provider "${providerKey}". Allowed: ${Object.keys(AUTH_PROVIDERS).join(', ')}`,
hint: 'Zitadel is not on Coolify v4 service catalog — use keycloak or authentik instead.',
},
{ status: 400 }
);
}
const name = slugify(body.name ?? providerKey);
try {
const created = await createService({
projectUuid: ws.coolify_project_uuid,
type: coolifyType,
name,
description: body.description ?? `AI-provisioned ${providerKey} for ${ws.slug}`,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
instantDeploy: body.instantDeploy ?? true,
});
const svc = await getService(created.uuid);
return NextResponse.json(
{
uuid: svc.uuid,
name: svc.name,
provider: providerKey,
status: svc.status ?? null,
},
{ status: 201 }
);
} catch (err) {
return NextResponse.json(
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
/**
* Authoritative: Coolify stores the template slug on `service_type`.
* Fall back to a name-prefix match so services created before that field
* existed still classify correctly.
*/
function resolveProviderSlug(svc: { name: string; service_type?: string }): string {
if (svc.service_type && AUTH_PROVIDER_SLUGS.has(svc.service_type)) return svc.service_type;
const candidates = Object.values(AUTH_PROVIDERS).sort((a, b) => b.length - a.length);
for (const slug of candidates) {
if (svc.name === slug || svc.name.startsWith(`${slug}-`) || svc.name.startsWith(`${slug}_`)) {
return slug;
}
}
return '';
}

View File

@@ -0,0 +1,194 @@
/**
* GET /api/workspaces/[slug]/bootstrap.sh
*
* One-shot installer. Intended usage inside a repo:
*
* curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \
* https://vibnai.com/api/workspaces/<slug>/bootstrap.sh | sh
*
* Writes three files into the cwd:
* - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents)
* - .cursor/mcp.json (registers /api/mcp as an MCP server)
* - .env.local (appends VIBN_* envs; never overwrites)
*
* Auth: caller MUST already have a `vibn_sk_...` token. We embed the
* same token in the generated mcp.json so Cursor agents can re-use it.
* Session auth works too but then nothing is embedded (the user gets
* placeholder strings to fill in themselves).
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
const tokenFromHeader = extractBearer(request);
// For API-key callers we can safely echo the token they sent us
// back into the generated files. For session callers we emit a
// placeholder — we don't want to re-issue long-lived tokens from
// a cookie-authenticated browser request.
const embedToken =
principal.source === 'api_key' && tokenFromHeader
? tokenFromHeader
: '<paste your vibn_sk_ token here>';
const script = buildScript({
slug: ws.slug,
giteaOrg: ws.gitea_org ?? '(unprovisioned)',
coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)',
appBase: APP_BASE,
token: embedToken,
});
return new NextResponse(script, {
status: 200,
headers: {
'Content-Type': 'text/x-shellscript; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function extractBearer(request: Request): string | null {
const a = request.headers.get('authorization');
if (!a) return null;
const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim());
return m?.[1] ?? null;
}
function buildScript(opts: {
slug: string;
giteaOrg: string;
coolifyProjectUuid: string;
appBase: string;
token: string;
}): string {
const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts;
// Build the file bodies in TS so we can shell-escape them cleanly
// using base64. The script itself does no string interpolation on
// these payloads — it just decodes and writes.
const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase });
const mcp = JSON.stringify(
{
mcpServers: {
[`vibn-${slug}`]: {
url: `${appBase}/api/mcp`,
headers: { Authorization: `Bearer ${token}` },
},
},
},
null,
2
);
const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`;
const b64Rule = Buffer.from(rule, 'utf8').toString('base64');
const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64');
const b64Env = Buffer.from(env, 'utf8').toString('base64');
return `#!/usr/bin/env sh
# Vibn workspace bootstrap — generated ${new Date().toISOString()}
# Workspace: ${slug}
#
# Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json,
# and appends VIBN_* env vars to .env.local (never overwrites).
set -eu
mkdir -p .cursor/rules
echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc
echo " wrote .cursor/rules/vibn-workspace.mdc"
echo "${b64Mcp}" | base64 -d > .cursor/mcp.json
echo " wrote .cursor/mcp.json"
if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then
echo " .env.local already has VIBN_* — skipping env append"
else
printf '\\n# Vibn workspace ${slug}\\n' >> .env.local
echo "${b64Env}" | base64 -d >> .env.local
echo " appended VIBN_* to .env.local"
fi
if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then
echo '.env.local' >> .gitignore
echo " added .env.local to .gitignore"
fi
echo ""
echo "Vibn workspace '${slug}' is wired up."
echo "Restart Cursor to pick up the new MCP server."
`;
}
function buildCursorRule(opts: {
slug: string;
giteaOrg: string;
coolifyProjectUuid: string;
appBase: string;
}): string {
const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts;
return `---
description: Vibn workspace "${slug}" — one-shot setup for AI agents
alwaysApply: true
---
# Vibn workspace: ${slug}
You are acting on behalf of the Vibn workspace **${slug}**. All AI
integration with Gitea and Coolify happens through the Vibn REST API,
which enforces tenancy for you.
## How to act
1. Before any git or deploy work, call:
\`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\`
with \`Authorization: Bearer $VIBN_API_KEY\` to get a
workspace-scoped bot username, PAT, and clone URL template.
2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted)
as the git remote. Never pass the root admin token to git.
3. For deploys, logs, env vars, call the workspace-scoped Coolify
endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`.
Any cross-tenant attempt is rejected with HTTP 403.
## Identity
- Gitea org: \`${giteaOrg}\`
- Coolify project uuid: \`${coolifyProjectUuid}\`
- API base: \`${appBase}\`
## Useful endpoints
| Method | Path |
|-------:|----------------------------------------------------------------|
| GET | /api/workspaces/${slug} |
| GET | /api/workspaces/${slug}/gitea-credentials |
| GET | /api/workspaces/${slug}/apps |
| GET | /api/workspaces/${slug}/apps/{uuid} |
| POST | /api/workspaces/${slug}/apps/{uuid}/deploy |
| GET | /api/workspaces/${slug}/apps/{uuid}/envs |
| PATCH | /api/workspaces/${slug}/apps/{uuid}/envs |
| DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO |
| POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) |
## Rules
- Never print or commit \`$VIBN_API_KEY\`.
- Prefer PRs over force-pushing \`main\`.
- If you see HTTP 403 on Coolify ops, you're trying to touch an app
outside this workspace — stop and ask the user.
- Re-run \`bootstrap.sh\` instead of hand-editing these files.
`;
}

View File

@@ -0,0 +1,158 @@
/**
* GET /api/workspaces/[slug]/databases/[uuid] — database details (incl. URLs)
* PATCH /api/workspaces/[slug]/databases/[uuid] — update fields
* DELETE /api/workspaces/[slug]/databases/[uuid]?confirm=<name>
* Volumes KEPT by default (data). Pass &delete_volumes=true to drop.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getDatabaseInProject,
updateDatabase,
deleteDatabase,
projectUuidOf,
TenantError,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
const db = await getDatabaseInProject(uuid, ws.coolify_project_uuid);
return NextResponse.json({
uuid: db.uuid,
name: db.name,
type: db.type ?? null,
status: db.status,
isPublic: db.is_public ?? false,
publicPort: db.public_port ?? null,
internalUrl: db.internal_db_url ?? null,
externalUrl: db.external_db_url ?? null,
projectUuid: projectUuidOf(db),
});
} catch (err) {
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
await getDatabaseInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
}
let body: Record<string, unknown> = {};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const allowed = new Set([
'name',
'description',
'is_public',
'public_port',
'image',
'limits_memory',
'limits_cpus',
]);
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body)) {
if (allowed.has(k) && v !== undefined) patch[k] = v;
}
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 });
}
try {
await updateDatabase(uuid, patch);
return NextResponse.json({ ok: true, uuid });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
let db;
try {
db = await getDatabaseInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
}
const url = new URL(request.url);
const confirm = url.searchParams.get('confirm');
if (confirm !== db.name) {
return NextResponse.json(
{ error: 'Confirmation required', hint: `Pass ?confirm=${db.name}` },
{ status: 409 }
);
}
// Default: preserve volumes (it's a database — user data lives there).
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false';
const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false';
const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false';
try {
await deleteDatabase(uuid, {
deleteConfigurations,
deleteVolumes,
deleteConnectedNetworks,
dockerCleanup,
});
return NextResponse.json({
ok: true,
deleted: { uuid, name: db.name, volumesKept: !deleteVolumes },
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,161 @@
/**
* GET /api/workspaces/[slug]/databases — list databases in this workspace
* POST /api/workspaces/[slug]/databases — provision a new database
*
* Supported `type` values (all that Coolify v4 can deploy):
* postgresql | mysql | mariadb | mongodb | redis | keydb | dragonfly | clickhouse
*
* POST body:
* {
* type: "postgresql",
* name?: "my-db",
* isPublic?: true, // expose a host port for remote clients
* publicPort?: 5433,
* image?: "postgres:16",
* credentials?: { ... } // type-specific (e.g. postgres_user)
* limits?: { memory?: "1G", cpus?: "1" },
* }
*
* Tenancy: every returned record is filtered to the workspace's own
* Coolify project UUID.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
listDatabasesInProject,
createDatabase,
getDatabase,
projectUuidOf,
type CoolifyDatabaseType,
} from '@/lib/coolify';
import { slugify } from '@/lib/naming';
const SUPPORTED_TYPES: readonly CoolifyDatabaseType[] = [
'postgresql',
'mysql',
'mariadb',
'mongodb',
'redis',
'keydb',
'dragonfly',
'clickhouse',
];
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: 'Workspace has no Coolify project yet', databases: [] },
{ status: 503 }
);
}
try {
const dbs = await listDatabasesInProject(ws.coolify_project_uuid);
return NextResponse.json({
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
databases: dbs.map(d => ({
uuid: d.uuid,
name: d.name,
type: d.type ?? null,
status: d.status,
isPublic: d.is_public ?? false,
publicPort: d.public_port ?? null,
projectUuid: projectUuidOf(d),
})),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
type Body = {
type?: string;
name?: string;
description?: string;
isPublic?: boolean;
publicPort?: number;
image?: string;
credentials?: Record<string, unknown>;
limits?: { memory?: string; cpus?: string };
instantDeploy?: boolean;
};
let body: Body = {};
try {
body = (await request.json()) as Body;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const type = body.type as CoolifyDatabaseType | undefined;
if (!type || !SUPPORTED_TYPES.includes(type)) {
return NextResponse.json(
{ error: `\`type\` must be one of: ${SUPPORTED_TYPES.join(', ')}` },
{ status: 400 }
);
}
const name = slugify(body.name ?? `${type}-${Date.now().toString(36)}`);
try {
const created = await createDatabase({
type,
name,
description: body.description,
projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
isPublic: body.isPublic,
publicPort: body.publicPort,
image: body.image,
credentials: body.credentials,
limits: body.limits,
instantDeploy: body.instantDeploy ?? true,
});
const db = await getDatabase(created.uuid);
return NextResponse.json(
{
uuid: db.uuid,
name: db.name,
type: db.type ?? type,
status: db.status,
isPublic: db.is_public ?? false,
publicPort: db.public_port ?? null,
internalUrl: db.internal_db_url ?? null,
externalUrl: db.external_db_url ?? null,
},
{ status: 201 }
);
} catch (err) {
return NextResponse.json(
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,53 @@
/**
* GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs
*
* Raw deployment logs. We can't tell from a deployment UUID alone
* which project it belongs to, so we require `?appUuid=...` and
* verify that app belongs to the workspace first. This keeps the
* tenant boundary intact even though Coolify's log endpoint is
* global.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getApplicationInProject,
getDeploymentLogs,
TenantError,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> }
) {
const { slug, deploymentUuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
const appUuid = new URL(request.url).searchParams.get('appUuid');
if (!appUuid) {
return NextResponse.json(
{ error: 'Query param "appUuid" is required for tenant enforcement' },
{ status: 400 }
);
}
try {
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
const logs = await getDeploymentLogs(deploymentUuid);
return NextResponse.json(logs);
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,77 @@
/**
* POST /api/workspaces/[slug]/domains/[domain]/attach
*
* Wires a registered domain up to a Coolify app (or arbitrary IP/CNAME)
* in one call. Idempotent — safe to retry.
*
* The heavy lifting lives in `lib/domain-attach.ts` so the MCP tool of the
* same name executes the same workflow.
*
* Body:
* {
* appUuid?: string,
* ip?: string,
* cname?: string,
* subdomains?: string[] // default ["@", "www"]
* updateRegistrarNs?: boolean // default true
* }
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { attachDomain, AttachError, type AttachInput } from '@/lib/domain-attach';
import { getDomainForWorkspace } from '@/lib/domains';
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string; domain: string }> },
) {
const { slug, domain: domainRaw } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
const apex = decodeURIComponent(domainRaw).toLowerCase().trim();
const row = await getDomainForWorkspace(ws.id, apex);
if (!row) {
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
}
let body: AttachInput = {};
try {
body = await request.json();
} catch {
// empty body is acceptable for a no-op attach check
}
try {
const result = await attachDomain(ws, row, body);
return NextResponse.json({
ok: true,
domain: {
id: result.domain.id,
domain: result.domain.domain,
dnsProvider: result.domain.dns_provider,
dnsZoneId: result.domain.dns_zone_id,
dnsNameservers: result.domain.dns_nameservers,
},
zone: result.zone,
records: result.records,
registrarNsUpdate: result.registrarNsUpdate,
coolifyUpdate: result.coolifyUpdate,
});
} catch (err) {
if (err instanceof AttachError) {
return NextResponse.json(
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
{ status: err.status },
);
}
console.error('[domains.attach] unexpected', err);
return NextResponse.json(
{ error: 'Attach failed', details: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,63 @@
/**
* GET /api/workspaces/[slug]/domains/[domain]
*
* Returns the full domain record (sans encrypted registrar password) plus
* recent lifecycle events. Used by the UI and agents to check status after
* a register call.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { query } from '@/lib/db-postgres';
import { getDomainForWorkspace } from '@/lib/domains';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; domain: string }> },
) {
const { slug, domain } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const row = await getDomainForWorkspace(principal.workspace.id, decodeURIComponent(domain));
if (!row) {
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
}
const events = await query<{
id: string;
type: string;
payload: Record<string, unknown>;
created_at: Date;
}>(
`SELECT id, type, payload, created_at
FROM vibn_domain_events
WHERE domain_id = $1
ORDER BY created_at DESC
LIMIT 20`,
[row.id],
);
return NextResponse.json({
id: row.id,
domain: row.domain,
tld: row.tld,
status: row.status,
registrar: row.registrar,
registrarOrderId: row.registrar_order_id,
registrarUsername: row.registrar_username,
periodYears: row.period_years,
whoisPrivacy: row.whois_privacy,
autoRenew: row.auto_renew,
registeredAt: row.registered_at,
expiresAt: row.expires_at,
dnsProvider: row.dns_provider,
dnsZoneId: row.dns_zone_id,
dnsNameservers: row.dns_nameservers,
pricePaidCents: row.price_paid_cents,
priceCurrency: row.price_currency,
createdAt: row.created_at,
updatedAt: row.updated_at,
events,
});
}

View File

@@ -0,0 +1,238 @@
/**
* Workspace-owned domains.
*
* GET /api/workspaces/[slug]/domains — list domains owned by the workspace
* POST /api/workspaces/[slug]/domains — register a domain through OpenSRS
*
* POST body:
* {
* domain: "example.com",
* period?: 1,
* whoisPrivacy?: true,
* contact: {
* first_name, last_name, org_name?,
* address1, address2?, city, state, country, postal_code,
* phone, fax?, email
* },
* nameservers?: string[],
* ca?: { cprCategory, legalType } // required for .ca
* }
*
* Safety rails:
* - `OPENSRS_MODE=test` is strongly recommended until we've verified live
* registration end-to-end. The handler reads the current mode from env
* and echoes it in the response so agents can tell.
* - We guard against duplicate POSTs by reusing an existing `pending` row
* for the same (workspace, domain) pair — caller can retry safely.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
domainTld,
registerDomain,
OpenSrsError,
minPeriodFor,
type RegistrationContact,
} from '@/lib/opensrs';
import {
createDomainIntent,
getDomainForWorkspace,
listDomainsForWorkspace,
markDomainFailed,
markDomainRegistered,
recordDomainEvent,
recordLedgerEntry,
} from '@/lib/domains';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const rows = await listDomainsForWorkspace(principal.workspace.id);
return NextResponse.json({
workspace: { slug: principal.workspace.slug },
mode: process.env.OPENSRS_MODE ?? 'test',
domains: rows.map(r => ({
id: r.id,
domain: r.domain,
tld: r.tld,
status: r.status,
registrar: r.registrar,
periodYears: r.period_years,
whoisPrivacy: r.whois_privacy,
autoRenew: r.auto_renew,
registeredAt: r.registered_at,
expiresAt: r.expires_at,
dnsProvider: r.dns_provider,
dnsNameservers: r.dns_nameservers,
pricePaidCents: r.price_paid_cents,
priceCurrency: r.price_currency,
createdAt: r.created_at,
})),
});
}
interface PostBody {
domain?: string;
period?: number;
whoisPrivacy?: boolean;
contact?: RegistrationContact;
nameservers?: string[];
ca?: { cprCategory: string; legalType: string };
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
let body: PostBody = {};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const raw = (body.domain ?? '').toString().trim().toLowerCase()
.replace(/^https?:\/\//, '').replace(/\/+$/, '');
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 });
}
const contactValidation = validateContact(body.contact);
if (contactValidation) return contactValidation;
const tld = domainTld(raw);
if (tld === 'ca' && !body.ca) {
return NextResponse.json(
{ error: '.ca registration requires { ca: { cprCategory, legalType } }' },
{ status: 400 },
);
}
const period = minPeriodFor(tld, typeof body.period === 'number' ? body.period : 1);
// Reuse an existing pending intent to keep POSTs idempotent.
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
if (intent && intent.status === 'active') {
return NextResponse.json(
{ error: `Domain ${raw} is already registered in this workspace`, domainId: intent.id },
{ status: 409 },
);
}
if (!intent) {
intent = await createDomainIntent({
workspaceId: principal.workspace.id,
domain: raw,
createdBy: principal.userId,
periodYears: period,
whoisPrivacy: body.whoisPrivacy ?? true,
});
}
await recordDomainEvent({
domainId: intent.id,
workspaceId: principal.workspace.id,
type: 'register.attempt',
payload: { period, mode: process.env.OPENSRS_MODE ?? 'test' },
});
try {
const result = await registerDomain({
domain: raw,
period,
contact: body.contact as RegistrationContact,
nameservers: body.nameservers,
whoisPrivacy: body.whoisPrivacy ?? true,
ca: body.ca,
});
const priceCents: number | null = null; // registrar price is captured at search time; later we'll pull the real reseller debit from get_balance_changes
const currency = process.env.OPENSRS_CURRENCY ?? 'CAD';
const updated = await markDomainRegistered({
domainId: intent.id,
registrarOrderId: result.orderId,
registrarUsername: result.regUsername,
registrarPassword: result.regPassword,
periodYears: period,
pricePaidCents: priceCents,
priceCurrency: currency,
registeredAt: new Date(),
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
});
if (priceCents) {
await recordLedgerEntry({
workspaceId: principal.workspace.id,
kind: 'debit',
amountCents: priceCents,
currency,
refType: 'domain.register',
refId: intent.id,
note: `Register ${raw} (${period}y)`,
});
}
await recordDomainEvent({
domainId: intent.id,
workspaceId: principal.workspace.id,
type: 'register.success',
payload: { orderId: result.orderId, period, mode: process.env.OPENSRS_MODE ?? 'test' },
});
return NextResponse.json({
ok: true,
mode: process.env.OPENSRS_MODE ?? 'test',
domain: {
id: updated.id,
domain: updated.domain,
tld: updated.tld,
status: updated.status,
periodYears: updated.period_years,
registeredAt: updated.registered_at,
expiresAt: updated.expires_at,
registrarOrderId: updated.registrar_order_id,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await markDomainFailed(intent.id, message);
if (err instanceof OpenSrsError) {
return NextResponse.json(
{ error: 'Registration failed', registrarCode: err.code, details: err.message },
{ status: 502 },
);
}
return NextResponse.json(
{ error: 'Registration failed', details: message },
{ status: 500 },
);
}
}
function validateContact(c?: RegistrationContact): NextResponse | null {
if (!c) return NextResponse.json({ error: '`contact` is required' }, { status: 400 });
const required: (keyof RegistrationContact)[] = [
'first_name', 'last_name', 'address1', 'city', 'state', 'country', 'postal_code', 'phone', 'email',
];
for (const k of required) {
if (!c[k] || typeof c[k] !== 'string' || !(c[k] as string).trim()) {
return NextResponse.json({ error: `contact.${k} is required` }, { status: 400 });
}
}
if (!/^[A-Z]{2}$/.test(c.country)) {
return NextResponse.json({ error: 'contact.country must be an ISO 3166-1 alpha-2 code' }, { status: 400 });
}
if (!/@/.test(c.email)) {
return NextResponse.json({ error: 'contact.email must be a valid email' }, { status: 400 });
}
return null;
}

View File

@@ -0,0 +1,94 @@
/**
* POST /api/workspaces/[slug]/domains/search
*
* Checks availability + pricing for one or more candidate domains against
* OpenSRS. Stateless; doesn't touch the DB.
*
* Body: { names: string[], period?: number }
* - names: up to 25 fully-qualified names (e.g. "vibnai.com", "vibn.io")
* - period: desired registration period in years (default 1). Auto-bumped
* to the registry minimum for quirky TLDs (e.g. .ai = 2 yrs).
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { checkDomain, OpenSrsError } from '@/lib/opensrs';
const MAX_NAMES = 25;
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
let body: { names?: unknown; period?: unknown } = {};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const names = Array.isArray(body.names)
? (body.names as unknown[]).filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
: [];
if (names.length === 0) {
return NextResponse.json(
{ error: 'Body must contain { names: string[] } with at least one domain' },
{ status: 400 },
);
}
if (names.length > MAX_NAMES) {
return NextResponse.json(
{ error: `Too many names (max ${MAX_NAMES})` },
{ status: 400 },
);
}
const period = typeof body.period === 'number' && body.period > 0 ? body.period : 1;
const results = await Promise.all(
names.map(async raw => {
const name = raw.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
try {
const r = await checkDomain(name, period);
return {
domain: name,
available: r.available,
price: r.price ?? null,
currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'),
period: r.period ?? period,
responseCode: r.responseCode,
responseText: r.responseText,
};
} catch (err) {
if (err instanceof OpenSrsError) {
return {
domain: name,
available: false,
price: null,
currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
period,
error: err.message,
responseCode: err.code,
};
}
return {
domain: name,
available: false,
price: null,
currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
period,
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
return NextResponse.json({
workspace: { slug: principal.workspace.slug },
mode: process.env.OPENSRS_MODE ?? 'test',
results,
});
}

View File

@@ -0,0 +1,85 @@
/**
* GET /api/workspaces/[slug]/gitea-credentials
*
* Returns a ready-to-use git clone URL for the workspace's Gitea org,
* plus the bot username/token. This is the one endpoint an AI agent
* calls before doing any git work — it hides all the admin/org/bot
* bookkeeping behind a single bearer-auth request.
*
* Auth: NextAuth session (owner) OR `Bearer vibn_sk_...` scoped to
* this workspace. Never returns credentials for a different workspace.
*
* The plaintext PAT is decrypted on the server on every call — we
* never persist it in logs or client state.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
// If the bot has never been provisioned, do it now. Idempotent.
let workspace = principal.workspace;
if (!workspace.gitea_bot_token_encrypted || !workspace.gitea_org) {
try {
workspace = await ensureWorkspaceProvisioned(workspace);
} catch (err) {
return NextResponse.json(
{
error: 'Provisioning failed',
details: err instanceof Error ? err.message : String(err),
},
{ status: 502 }
);
}
}
const creds = getWorkspaceBotCredentials(workspace);
if (!creds) {
return NextResponse.json(
{
error: 'Workspace has no Gitea bot yet',
provisionStatus: workspace.provision_status,
provisionError: workspace.provision_error,
hint:
'POST /api/workspaces/' +
slug +
'/provision to retry bot provisioning.',
},
{ status: 503 }
);
}
const apiBase = GITEA_API_URL.replace(/\/$/, '');
const host = new URL(apiBase).host;
return NextResponse.json({
workspace: { slug: workspace.slug, giteaOrg: creds.org },
bot: {
username: creds.username,
// Full plaintext PAT — treat like a password.
token: creds.token,
},
gitea: {
apiBase,
host,
// Templates for the agent. Substitute {{repo}} with the repo name.
cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`,
sshRemoteTemplate: `git@${host}:${creds.org}/{{repo}}.git`,
webUrlTemplate: `${apiBase}/${creds.org}/{{repo}}`,
},
principal: {
source: principal.source,
apiKeyId: principal.apiKeyId ?? null,
},
});
}

View File

@@ -0,0 +1,28 @@
/**
* DELETE /api/workspaces/[slug]/keys/[keyId] — revoke a workspace API key
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal, revokeWorkspaceApiKey } from '@/lib/auth/workspace-auth';
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; keyId: string }> }
) {
const { slug, keyId } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
if (principal.source !== 'session') {
return NextResponse.json(
{ error: 'API keys can only be revoked from a signed-in session' },
{ status: 403 }
);
}
const ok = await revokeWorkspaceApiKey(principal.workspace.id, keyId);
if (!ok) {
return NextResponse.json({ error: 'Key not found or already revoked' }, { status: 404 });
}
return NextResponse.json({ revoked: true });
}

View File

@@ -0,0 +1,72 @@
/**
* Per-workspace API keys for AI agents.
*
* GET /api/workspaces/[slug]/keys — list keys (no secrets)
* POST /api/workspaces/[slug]/keys — mint a new key
*
* The full plaintext key is returned ONCE in the POST response and never
* persisted; only its sha256 hash is stored.
*
* API-key principals can list their own workspace's keys but cannot mint
* new ones (use the session UI for that).
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { listWorkspaceApiKeys, mintWorkspaceApiKey } from '@/lib/auth/workspace-auth';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const keys = await listWorkspaceApiKeys(principal.workspace.id);
return NextResponse.json({ keys });
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
if (principal.source !== 'session') {
return NextResponse.json(
{ error: 'API keys can only be created from a signed-in session' },
{ status: 403 }
);
}
let body: { name?: string; scopes?: string[] };
try {
body = (await request.json()) as { name?: string; scopes?: string[] };
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const name = (body.name ?? '').trim();
if (!name) {
return NextResponse.json({ error: 'Field "name" is required' }, { status: 400 });
}
const minted = await mintWorkspaceApiKey({
workspaceId: principal.workspace.id,
name,
createdBy: principal.userId,
scopes: body.scopes,
});
return NextResponse.json({
id: minted.id,
name: minted.name,
prefix: minted.prefix,
createdAt: minted.created_at,
// ↓ Only returned ONCE. Client must store this — we never see it again.
token: minted.token,
});
}

View File

@@ -0,0 +1,29 @@
/**
* POST /api/workspaces/[slug]/provision — (re)run Coolify + Gitea provisioning
*
* Idempotent. Useful when initial provisioning during signin or first
* project create failed because Coolify or Gitea was unavailable.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { ensureWorkspaceProvisioned } from '@/lib/workspaces';
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const updated = await ensureWorkspaceProvisioned(principal.workspace);
return NextResponse.json({
slug: updated.slug,
coolifyProjectUuid: updated.coolify_project_uuid,
giteaOrg: updated.gitea_org,
provisionStatus: updated.provision_status,
provisionError: updated.provision_error,
});
}

View File

@@ -0,0 +1,35 @@
/**
* GET /api/workspaces/[slug] — workspace details
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const w = principal.workspace;
return NextResponse.json({
id: w.id,
slug: w.slug,
name: w.name,
coolifyProjectUuid: w.coolify_project_uuid,
coolifyTeamId: w.coolify_team_id,
giteaOrg: w.gitea_org,
giteaBotUsername: w.gitea_bot_username,
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
provisionStatus: w.provision_status,
provisionError: w.provision_error,
createdAt: w.created_at,
updatedAt: w.updated_at,
principal: {
source: principal.source,
apiKeyId: principal.apiKeyId ?? null,
},
});
}

View File

@@ -0,0 +1,69 @@
/**
* GET /api/workspaces — list workspaces the caller can access
*
* Auth:
* - NextAuth session: returns the user's owned + member workspaces
* - vibn_sk_... API key: returns just the one workspace the key is bound to
*/
import { NextResponse } from 'next/server';
import { authSession } from '@/lib/auth/session-server';
import { queryOne } from '@/lib/db-postgres';
import { ensureWorkspaceForUser, listWorkspacesForUser } from '@/lib/workspaces';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
export async function GET(request: Request) {
if (request.headers.get('authorization')?.toLowerCase().startsWith('bearer vibn_sk_')) {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
return NextResponse.json({ workspaces: [serializeWorkspace(principal.workspace)] });
}
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userRow = await queryOne<{ id: string }>(
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[session.user.email]
);
if (!userRow) {
return NextResponse.json({ workspaces: [] });
}
// Migration path: users who signed in before the signIn hook was
// added (or before vibn_workspaces existed) have no row yet. Create
// one on first list so the UI never shows an empty state for them.
let list = await listWorkspacesForUser(userRow.id);
if (list.length === 0) {
try {
await ensureWorkspaceForUser({
userId: userRow.id,
email: session.user.email,
displayName: session.user.name ?? null,
});
list = await listWorkspacesForUser(userRow.id);
} catch (err) {
console.error('[api/workspaces] lazy ensure failed', err);
}
}
return NextResponse.json({ workspaces: list.map(serializeWorkspace) });
}
function serializeWorkspace(w: import('@/lib/workspaces').VibnWorkspace) {
return {
id: w.id,
slug: w.slug,
name: w.name,
coolifyProjectUuid: w.coolify_project_uuid,
giteaOrg: w.gitea_org,
giteaBotUsername: w.gitea_bot_username,
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
provisionStatus: w.provision_status,
provisionError: w.provision_error,
createdAt: w.created_at,
updatedAt: w.updated_at,
};
}

View File

@@ -5,22 +5,27 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, Suspense } from "react";
import NextAuthComponent from "@/app/components/NextAuthComponent";
function deriveWorkspace(email: string): string {
return email.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-account";
}
function AuthPageInner() {
const { status } = useSession();
const { data: session, status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
if (status === "authenticated") {
if (status === "authenticated" && session?.user?.email) {
const callbackUrl = searchParams.get("callbackUrl");
// Only follow external callbackUrls we control (Theia subdomain)
if (callbackUrl && callbackUrl.startsWith("https://theia.vibnai.com")) {
window.location.href = callbackUrl;
} else {
router.push("/marks-account/projects");
const workspace = deriveWorkspace(session.user.email);
router.push(`/${workspace}/projects`);
}
}
}, [status, router, searchParams]);
}, [status, session, router, searchParams]);
if (status === "loading") {
return (

View File

@@ -13,7 +13,7 @@ export default function NextAuthComponent() {
try {
// Sign in with Google using NextAuth
await signIn("google", {
callbackUrl: "/marks-account/projects",
callbackUrl: "/auth",
});
} catch (error) {
console.error("Google sign-in error:", error);

View File

@@ -8,11 +8,22 @@
@keyframes vibn-breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.15)} }
.vibn-enter { animation: vibn-enter 0.35s ease both; }
/* Marketing — Justine ink & parchment (no blue/purple chrome) */
.vibn-gradient-text {
background-image: linear-gradient(90deg, var(--vibn-mid) 0%, var(--vibn-ink) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.vibn-cta-surface {
background-image: linear-gradient(to bottom right, var(--vibn-cream), var(--vibn-parch));
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-outfit);
--font-serif: var(--font-newsreader);
--font-sans: var(--font-inter);
--font-serif: var(--font-lora);
--font-mono: var(--font-ibm-plex-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@@ -51,38 +62,50 @@
:root {
--radius: 0.5rem;
/* Stackless warm beige palette */
--background: #f6f4f0;
--foreground: #1a1a1a;
--card: #ffffff;
--card-foreground: #1a1a1a;
--popover: #ffffff;
--popover-foreground: #1a1a1a;
--primary: #1a1a1a;
--primary-foreground: #ffffff;
--secondary: #f0ece4;
--secondary-foreground: #1a1a1a;
--muted: #f0ece4;
--muted-foreground: #a09a90;
--accent: #eae6de;
--accent-foreground: #1a1a1a;
--destructive: #d32f2f;
--border: #e8e4dc;
--input: #e0dcd4;
--ring: #d0ccc4;
/* Justine UX pack — ink & parchment (aligned with master-ai/justine/00_design-tokens.css) */
--vibn-ink: #1a1510;
--vibn-ink2: #2c2c2a;
--vibn-ink3: #444441;
--vibn-mid: #5f5e5a;
--vibn-muted: #888780;
--vibn-stone: #b4b2a9;
--vibn-parch: #d3d1c7;
--vibn-cream: #f1efe8;
--vibn-paper: #f7f4ee;
--vibn-white: #fdfcfa;
--vibn-border: #e8e2d9;
--background: var(--vibn-paper);
--foreground: var(--vibn-ink);
--card: var(--vibn-white);
--card-foreground: var(--vibn-ink);
--popover: var(--vibn-white);
--popover-foreground: var(--vibn-ink);
--primary: var(--vibn-ink);
--primary-foreground: var(--vibn-paper);
--secondary: var(--vibn-cream);
--secondary-foreground: var(--vibn-ink);
--muted: var(--vibn-cream);
--muted-foreground: var(--vibn-muted);
--accent: var(--vibn-cream);
--accent-foreground: var(--vibn-ink);
--destructive: #b42318;
--border: var(--vibn-border);
--input: var(--vibn-border);
--ring: var(--vibn-stone);
--chart-1: oklch(0.70 0.15 60);
--chart-2: oklch(0.70 0.12 210);
--chart-3: oklch(0.55 0.10 220);
--chart-4: oklch(0.40 0.08 230);
--chart-5: oklch(0.75 0.15 70);
--sidebar: #ffffff;
--sidebar-foreground: #1a1a1a;
--sidebar-primary: #1a1a1a;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f6f4f0;
--sidebar-accent-foreground: #1a1a1a;
--sidebar-border: #e8e4dc;
--sidebar-ring: #d0ccc4;
--sidebar: var(--vibn-white);
--sidebar-foreground: var(--vibn-ink);
--sidebar-primary: var(--vibn-ink);
--sidebar-primary-foreground: var(--vibn-paper);
--sidebar-accent: var(--vibn-paper);
--sidebar-accent-foreground: var(--vibn-ink);
--sidebar-border: var(--vibn-border);
--sidebar-ring: var(--vibn-stone);
}
.dark {
@@ -111,8 +134,8 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.85 0.02 85);
--sidebar-primary-foreground: oklch(0.18 0.02 60);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
@@ -125,21 +148,24 @@
}
body {
@apply bg-background text-foreground;
font-family: var(--font-outfit), 'Outfit', sans-serif;
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
h1, h2, h3 {
font-family: var(--font-lora), ui-serif, Georgia, serif;
}
button {
font-family: var(--font-outfit), 'Outfit', sans-serif;
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
input, textarea, select {
font-family: var(--font-outfit), 'Outfit', sans-serif;
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
input::placeholder {
color: #b5b0a6;
color: var(--muted-foreground);
}
::selection {
background: #1a1a1a;
color: #fff;
background: var(--foreground);
color: var(--background);
}
::-webkit-scrollbar {
width: 4px;
@@ -149,7 +175,7 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d0ccc4;
background: var(--vibn-stone);
border-radius: 10px;
}
}

View File

@@ -1,19 +1,19 @@
import type { Metadata } from "next";
import { Outfit, Newsreader, IBM_Plex_Mono } from "next/font/google";
import { Inter, Lora, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { Providers } from "@/app/components/Providers";
const outfit = Outfit({
variable: "--font-outfit",
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
weight: ["400", "500", "600", "700"],
});
const newsreader = Newsreader({
variable: "--font-newsreader",
const lora = Lora({
variable: "--font-lora",
subsets: ["latin"],
weight: ["400", "500"],
weight: ["400", "500", "600", "700"],
style: ["normal", "italic"],
});
@@ -24,8 +24,8 @@ const ibmPlexMono = IBM_Plex_Mono({
});
export const metadata: Metadata = {
title: "VIBN — Build with Atlas",
description: "Chat with Atlas to define your product, then let AI build it.",
title: "VIBN — Build with Vibn",
description: "Chat with Vibn to define your product, then let AI build it.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
},
other: {
"mobile-web-app-capable": "yes",
"msapplication-TileColor": "#1a1a1a",
"msapplication-TileColor": "#1a1510",
},
};
@@ -47,12 +47,12 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="theme-color" content="#1a1510" />
<link rel="apple-touch-icon" href="/vibn-logo-circle.png" />
<link rel="manifest" href="/manifest.json" />
</head>
<body
className={`${outfit.variable} ${newsreader.variable} ${ibmPlexMono.variable} antialiased`}
className={`${inter.variable} ${lora.variable} ${ibmPlexMono.variable} antialiased`}
>
<Providers>
{children}
@@ -69,4 +69,3 @@ export default function RootLayout({
</html>
);
}

View File

@@ -0,0 +1,355 @@
/**
* Verbatim from justine/01_homepage.html <style>, scoped under [data-justine].
* Do not mix Tailwind/shadcn tokens on surfaces inside this root.
*/
[data-justine] {
--ink: #1a1a1a;
--ink2: #2c2c2a;
--ink3: #444441;
--mid: #6b7280;
--muted: #9ca3af;
--stone: #b4b2a9;
--parch: #d3d1c7;
--cream: #f1efe8;
--paper: #f7f4ee;
--white: #ffffff;
--border: #e5e7eb;
--serif: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
--sans: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
font-family: var(--sans);
background: linear-gradient(to bottom, #fafafe, #f0eeff);
min-height: 100vh;
color: var(--ink);
}
[data-justine] > main {
flex: 1;
width: 100%;
}
[data-justine] * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
[data-justine] .f {
font-family: var(--serif);
}
[data-justine] nav {
background: rgba(250, 250, 250, 0.95);
border-bottom: 1px solid var(--border);
padding: 0 52px;
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 50;
}
[data-justine] .nav-links {
display: flex;
gap: 32px;
align-items: center;
}
[data-justine] .btn-ink {
background: linear-gradient(135deg, #2e2a5e, #4338ca);
color: #ffffff;
border: none;
border-radius: 8px;
padding: 9px 22px;
font-family: var(--sans);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
[data-justine] .btn-ink:hover {
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15), 0 0 0 6px rgba(99, 102, 241, 0.15);
transform: translateY(-1px);
}
[data-justine] .btn-ink-lg {
background: linear-gradient(135deg, #2e2a5e, #4338ca);
color: #ffffff;
border: none;
border-radius: 10px;
padding: 15px 36px;
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
[data-justine] .btn-ink-lg:hover {
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15), 0 0 0 6px rgba(99, 102, 241, 0.15);
transform: translateY(-1px);
}
[data-justine] .gradient-em {
background: linear-gradient(to right, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-style: italic;
}
[data-justine] .gradient-text {
background: linear-gradient(to right, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-justine] .gradient-num {
background: linear-gradient(135deg, #2e2a5e, #4338ca);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-justine] .empathy-card {
background: var(--white);
border: 1px solid var(--border);
border-left: 3px solid rgba(99, 102, 241, 0.8);
border-radius: 12px;
padding: 18px 20px;
display: flex;
gap: 14px;
align-items: flex-start;
box-shadow: 0 10px 30px rgba(30, 27, 75, 0.05);
transition: border-color 0.2s ease, background 0.2s ease;
}
[data-justine] .empathy-card:hover {
border-color: #6366f1;
background: #fafaff;
}
[data-justine] .hero-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 96px;
align-items: center;
}
[data-justine] .empathy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 72px;
align-items: center;
}
[data-justine] .phase-grid {
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 14px;
overflow: hidden;
}
[data-justine] .wyg-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
[data-justine] .quote-grid {
display: grid;
grid-template-columns: 1fr 1.6fr 1fr;
gap: 28px;
align-items: center;
margin-bottom: 20px;
}
[data-justine] .stats-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
}
[data-justine] .footer-tagline {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 4px;
font-family: var(--sans);
}
[data-justine] .hamburger {
display: none;
flex-direction: column;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 6px;
}
[data-justine] .hamburger span {
display: block;
width: 22px;
height: 2px;
background: var(--ink);
border-radius: 2px;
transition: transform 0.25s ease, opacity 0.25s ease;
}
[data-justine] .hamburger.open span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
[data-justine] .hamburger.open span:nth-child(2) {
opacity: 0;
}
[data-justine] .hamburger.open span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
[data-justine] .mobile-menu {
display: none;
position: fixed;
top: 62px;
left: 0;
right: 0;
background: rgba(250, 250, 250, 0.98);
border-bottom: 1px solid var(--border);
padding: 20px 24px 28px;
z-index: 49;
flex-direction: column;
gap: 0;
box-shadow: 0 8px 24px rgba(30, 27, 75, 0.08);
}
[data-justine] .mobile-menu.open {
display: flex;
}
[data-justine] .mobile-menu a {
font-size: 15px;
color: var(--ink);
text-decoration: none;
padding: 13px 0;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
[data-justine] .mobile-menu a:last-of-type {
border-bottom: none;
}
[data-justine] .mobile-menu .mobile-menu-cta {
margin-top: 18px;
}
[data-justine] footer {
background: rgba(250, 250, 250, 0.95);
border-top: 1px solid var(--border);
padding: 32px 52px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
[data-justine] .footer-links {
display: flex;
gap: 28px;
}
@media (max-width: 768px) {
[data-justine] nav {
padding: 0 20px;
}
[data-justine] .nav-links {
display: none;
}
[data-justine] .nav-right-btns {
display: none !important;
}
[data-justine] .hamburger {
display: flex;
}
[data-justine] .hero-grid {
grid-template-columns: 1fr;
gap: 44px;
}
[data-justine] .hero-section {
padding: 52px 24px 48px !important;
}
[data-justine] .empathy-section {
padding: 56px 24px !important;
}
[data-justine] .empathy-grid {
grid-template-columns: 1fr;
gap: 36px;
}
[data-justine] .how-section {
padding: 64px 24px !important;
}
[data-justine] .phase-grid {
grid-template-columns: 1fr;
}
[data-justine] .phase-grid > div {
border-right: none !important;
padding: 28px 24px !important;
}
[data-justine] .wyg-grid {
grid-template-columns: 1fr;
}
[data-justine] .wyg-grid > div {
border-right: none !important;
border-bottom: 1px solid var(--border);
padding: 32px 24px !important;
}
[data-justine] .wyg-grid > div:last-child {
border-bottom: none;
}
[data-justine] .wyg-section {
padding: 0 24px !important;
}
[data-justine] .quote-grid {
grid-template-columns: 1fr;
}
[data-justine] .quote-side {
display: none !important;
}
[data-justine] .quote-section {
padding: 32px 24px 28px !important;
}
[data-justine] .stats-grid {
grid-template-columns: 1fr 1fr;
}
[data-justine] .stats-grid > div {
padding: 28px 16px !important;
}
[data-justine] .stats-grid > div:nth-child(odd) {
padding-left: 0 !important;
}
[data-justine] .stats-grid > div:nth-child(3),
[data-justine] .stats-grid > div:nth-child(4) {
border-top: 1px solid var(--border);
}
[data-justine] .stats-grid > div:nth-child(even) {
border-right: none !important;
}
[data-justine] .stats-section {
padding: 0 24px !important;
}
[data-justine] .cta-section {
padding: 56px 20px !important;
}
[data-justine] .cta-card {
padding: 44px 28px !important;
}
[data-justine] .hero-h1 {
font-size: 40px !important;
line-height: 1.1 !important;
}
[data-justine] .hero-sub {
font-size: 15px !important;
}
[data-justine] footer {
display: flex !important;
flex-direction: column;
gap: 20px;
text-align: center;
padding: 32px 24px !important;
}
[data-justine] .footer-links {
flex-wrap: wrap;
justify-content: center;
}
}

View File

@@ -140,7 +140,7 @@ function MessageRow({
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.68rem", fontWeight: 700,
color: isAtlas ? "#fff" : "#8a8478",
fontFamily: isAtlas ? "Newsreader, serif" : "Outfit, sans-serif",
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{isAtlas ? "A" : userInitial}
</div>
@@ -149,14 +149,14 @@ function MessageRow({
<div style={{
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
fontFamily: "Outfit, sans-serif",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{isAtlas ? "Atlas" : "You"}
{isAtlas ? "Vibn" : "You"}
</div>
{/* Content */}
<div style={{
fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72,
fontFamily: "Outfit, sans-serif",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
whiteSpace: isAtlas ? "normal" : "pre-wrap",
}}>
{renderContent(clean)}
@@ -175,7 +175,7 @@ function MessageRow({
color: saved ? "#2e7d32" : "#fff",
border: saved ? "1px solid #a5d6a7" : "none",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: saved || saving ? "default" : "pointer",
transition: "all 0.15s",
opacity: saving ? 0.7 : 1,
@@ -186,7 +186,7 @@ function MessageRow({
{!saved && (
<div style={{
marginTop: 6, fontSize: "0.72rem", color: "#a09a90",
fontFamily: "Outfit, sans-serif", lineHeight: 1.4,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", lineHeight: 1.4,
}}>
{phase.summary}
</div>
@@ -218,7 +218,7 @@ function MessageRow({
display: "inline-block", padding: "8px 16px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.76rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", textDecoration: "none",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", textDecoration: "none",
}}
>
Review architecture
@@ -234,7 +234,7 @@ function MessageRow({
style={{
padding: "7px 14px", borderRadius: 6, border: "1px solid #e0dcd4",
background: "none", fontSize: "0.74rem", color: "#6b6560",
fontFamily: "Outfit, sans-serif", cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
>
Try again
@@ -256,7 +256,7 @@ function MessageRow({
padding: "9px 18px", borderRadius: 8, border: "none",
background: archState === "loading" ? "#8a8478" : "#1a1a1a",
color: "#fff", fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: archState === "loading" ? "default" : "pointer",
transition: "background 0.15s",
}}
@@ -288,7 +288,7 @@ function TypingIndicator() {
<div style={{
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.68rem", fontWeight: 700, color: "#fff", fontFamily: "Newsreader, serif",
fontSize: "0.68rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-lora), ui-serif, serif",
}}>A</div>
<div style={{ display: "flex", gap: 5, paddingTop: 10 }}>
{[0, 1, 2].map(d => (
@@ -425,7 +425,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
return (
<div style={{
display: "flex", flexDirection: "column", height: "100%",
background: "#f6f4f0", fontFamily: "Outfit, sans-serif",
background: "#f6f4f0", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
<style>{`
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
@@ -443,12 +443,12 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
<div style={{
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 500, color: "#fff",
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, color: "#fff",
animation: "breathe 2.5s ease infinite",
}}>A</div>
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
<div style={{ textAlign: "center" }}>
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Atlas</p>
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Vibn</p>
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
Your product strategist. Let&apos;s define what you&apos;re building.
</p>
@@ -466,7 +466,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
style={{
position: "absolute", top: 12, right: 16,
background: "none", border: "none", cursor: "pointer",
fontSize: "0.68rem", color: "#d0ccc4", fontFamily: "Outfit, sans-serif",
fontSize: "0.68rem", color: "#d0ccc4", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
padding: "3px 7px", borderRadius: 4, transition: "color 0.12s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#8a8478")}
@@ -489,8 +489,44 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
</div>
)}
{/* Quick-action chips — shown when there's a conversation and AI isn't typing */}
{!isEmpty && !isStreaming && (
<div style={{ padding: "0 32px 8px", display: "flex", gap: 6, flexWrap: "wrap" }}>
{[
{ label: "Give me suggestions", prompt: "Can you give me some examples or suggestions to help me think through this?" },
{ label: "What's most important?", prompt: "What's the most important thing for me to nail down right now?" },
{ label: "Move on", prompt: "That's enough detail for now — let's move to the next phase." },
].map(({ label, prompt }) => (
<button
key={label}
onClick={() => sendToAtlas(prompt, false)}
style={{
padding: "5px 12px", borderRadius: 20,
border: "1px solid #e0dcd4",
background: "#fff", color: "#6b6560",
fontSize: "0.73rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: "pointer", transition: "all 0.1s",
whiteSpace: "nowrap",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.background = "#f0ece4";
(e.currentTarget as HTMLElement).style.borderColor = "#c8c4bc";
(e.currentTarget as HTMLElement).style.color = "#1a1a1a";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = "#fff";
(e.currentTarget as HTMLElement).style.borderColor = "#e0dcd4";
(e.currentTarget as HTMLElement).style.color = "#6b6560";
}}
>
{label}
</button>
))}
</div>
)}
{/* Input bar */}
<div style={{ padding: "14px 32px max(22px, env(safe-area-inset-bottom))", flexShrink: 0 }}>
<div style={{ padding: "6px 32px max(22px, env(safe-area-inset-bottom))", flexShrink: 0 }}>
<div style={{
display: "flex", gap: 8, padding: "5px 5px 5px 16px",
background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10,
@@ -505,7 +541,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
disabled={isStreaming}
style={{
flex: 1, border: "none", background: "none",
fontSize: "0.86rem", fontFamily: "Outfit, sans-serif",
fontSize: "0.86rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", padding: "8px 0",
resize: "none", outline: "none",
minHeight: 24, maxHeight: 120,
@@ -517,7 +553,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
style={{
padding: "9px 16px", borderRadius: 7, border: "none",
background: "#eae6de", color: "#8a8478",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: "pointer", flexShrink: 0,
display: "flex", alignItems: "center", gap: 6,
}}
@@ -533,7 +569,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
padding: "9px 16px", borderRadius: 7, border: "none",
background: input.trim() ? "#1a1a1a" : "#eae6de",
color: input.trim() ? "#fff" : "#b5b0a6",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: input.trim() ? "pointer" : "default",
flexShrink: 0, transition: "all 0.15s",
}}

View File

@@ -0,0 +1,287 @@
"use client";
import { useState, useEffect, useRef } from "react";
interface CooMessage {
id: string;
role: "user" | "assistant";
content: string;
source?: "atlas" | "coo"; // atlas = discovery history, coo = orchestrator response
streaming?: boolean;
}
export function CooChat({ projectId }: { projectId: string }) {
const [messages, setMessages] = useState<CooMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [historyLoaded, setHistoryLoaded] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Scroll to bottom whenever messages change
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Pre-load Atlas discovery history on mount
useEffect(() => {
fetch(`/api/projects/${projectId}/atlas-chat`)
.then(r => r.json())
.then((data: { messages?: Array<{ role: "user" | "assistant"; content: string }> }) => {
const atlasMessages: CooMessage[] = (data.messages ?? [])
.filter(m => m.content?.trim())
.map((m, i) => ({
id: `atlas_${i}`,
role: m.role,
content: m.content,
source: "atlas" as const,
}));
if (atlasMessages.length > 0) {
// Add a small divider message at the bottom of Atlas history
setMessages([
...atlasMessages,
{
id: "coo_divider",
role: "assistant",
content: "Discovery complete. I'm your product COO — I have the full context above. What do you need?",
source: "coo" as const,
},
]);
} else {
// No Atlas history — show default COO welcome
setMessages([{
id: "welcome",
role: "assistant",
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
source: "coo" as const,
}]);
}
setHistoryLoaded(true);
})
.catch(() => {
setMessages([{
id: "welcome",
role: "assistant",
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
source: "coo" as const,
}]);
setHistoryLoaded(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
setInput("");
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text, source: "coo" };
const assistantId = (Date.now() + 1).toString();
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", source: "coo", streaming: true };
setMessages(prev => [...prev, userMsg, assistantMsg]);
setLoading(true);
// Build history from COO messages only (skip atlas history for context to orchestrator)
const history = messages
.filter(m => m.source === "coo" && m.id !== "coo_divider" && m.content)
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
try {
const res = await fetch(`/api/projects/${projectId}/advisor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
});
if (!res.ok || !res.body) {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
: m));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: m.content + chunk }
: m));
}
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
} catch {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Connection error. Please try again.", streaming: false }
: m));
} finally {
setLoading(false);
textareaRef.current?.focus();
}
};
if (!historyLoaded) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ display: "flex", gap: 4 }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
width: 4, height: 4, borderRadius: "50%",
background: "#d4cfc6", display: "inline-block",
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
}} />
))}
</div>
</div>
);
}
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Messages */}
<div style={{ flex: 1, overflow: "auto", padding: "12px 14px 8px", display: "flex", flexDirection: "column", gap: 10 }}>
{messages.map((msg, idx) => {
const isAtlas = msg.source === "atlas";
const isUser = msg.role === "user";
const isCoo = !isUser && !isAtlas;
// Separator before the divider message
const prevMsg = messages[idx - 1];
const showSeparator = msg.id === "coo_divider" && prevMsg?.source === "atlas";
return (
<div key={msg.id}>
{showSeparator && (
<div style={{
display: "flex", alignItems: "center", gap: 8,
margin: "8px 0 4px", opacity: 0.5,
}}>
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
<span style={{ fontSize: "0.58rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
Discovery · COO
</span>
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
</div>
)}
<div style={{
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
alignItems: "flex-end",
gap: 6,
}}>
{/* Avatar */}
{!isUser && (
<span style={{
width: 18, height: 18, borderRadius: 5,
background: isAtlas ? "#4a6fa5" : "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: isAtlas ? "0.48rem" : "0.48rem",
color: "#fff", flexShrink: 0,
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "inherit",
fontWeight: isAtlas ? 700 : 400,
}}>
{isAtlas ? "A" : "◈"}
</span>
)}
<div style={{
maxWidth: "88%",
padding: isUser ? "7px 10px" : "0",
background: isUser ? "#f0ece4" : "transparent",
borderRadius: isUser ? 10 : 0,
fontSize: isAtlas ? "0.75rem" : "0.79rem",
color: isAtlas ? "#4a4540" : "#1a1a1a",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
opacity: isAtlas ? 0.85 : 1,
}}>
{msg.content}
{msg.streaming && msg.content === "" && (
<span style={{ display: "inline-flex", gap: 3, alignItems: "center", height: "1em" }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
width: 4, height: 4, borderRadius: "50%",
background: "#b5b0a6", display: "inline-block",
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
}} />
))}
</span>
)}
{msg.streaming && msg.content !== "" && (
<span style={{
display: "inline-block", width: 2, height: "0.85em",
background: "#1a1a1a", marginLeft: 1,
verticalAlign: "text-bottom",
animation: "cooBlink 1s step-end infinite",
}} />
)}
</div>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px 10px", background: "#fff" }}>
<div style={{ display: "flex", gap: 7, alignItems: "flex-end" }}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
}}
placeholder={loading ? "Thinking…" : "Ask anything…"}
disabled={loading}
rows={2}
style={{
flex: 1, resize: "none",
border: "1px solid #e8e4dc", borderRadius: 10,
padding: "8px 10px", fontSize: "0.79rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", outline: "none",
background: "#faf8f5", lineHeight: 1.5,
}}
/>
<button
onClick={send}
disabled={!input.trim() || loading}
style={{
width: 32, height: 32, flexShrink: 0,
border: "none", borderRadius: 8,
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
cursor: input.trim() && !loading ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.85rem",
}}
></button>
</div>
<div style={{ fontSize: "0.6rem", color: "#c5c0b8", marginTop: 5, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
send · Shift+ newline
</div>
</div>
<style>{`
@keyframes cooBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
@keyframes cooBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode, useEffect, useState } from "react";
import { VIBNSidebar } from "./vibn-sidebar";
import { ReactNode, Suspense } from "react";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { Toaster } from "sonner";
interface ProjectShellProps {
@@ -19,319 +19,141 @@ interface ProjectShellProps {
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
const TABS = [
{ id: "overview", label: "Atlas", path: "overview" },
{ id: "prd", label: "PRD", path: "prd" },
{ id: "design", label: "Design", path: "design" },
{ id: "build", label: "Build", path: "build" },
{ id: "deployment", label: "Launch", path: "deployment" },
{ id: "grow", label: "Grow", path: "grow" },
{ id: "insights", label: "Insights", path: "insights" },
{ id: "settings", label: "Settings", path: "settings" },
];
const SECTIONS = [
{ id: "overview", label: "Vibn", path: "overview" },
{ id: "prd", label: "PRD", path: "prd" },
{ id: "build", label: "Build", path: "build" },
{ id: "growth", label: "Growth", path: "growth" },
{ id: "assist", label: "Assist", path: "assist" },
{ id: "analytics", label: "Analytics", path: "analytics" },
] as const;
const DISCOVERY_PHASES = [
{ id: "big_picture", label: "Big Picture" },
{ id: "users_personas", label: "Users & Personas" },
{ id: "features_scope", label: "Features" },
{ id: "business_model", label: "Business Model" },
{ id: "screens_data", label: "Screens" },
{ id: "risks_questions", label: "Risks" },
];
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
function timeAgo(dateStr?: string): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const diff = (Date.now() - date.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
}}>
{children}
</div>
);
}
function StatusTag({ status }: { status?: string }) {
const label = status === "live" ? "Live" : status === "building" ? "Building" : "Defining";
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#9a7b3a";
const bg = status === "live" ? "#2e7d3210" : status === "building" ? "#3d5afe10" : "#d4a04a12";
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
color, background: bg, fontFamily: "Outfit, sans-serif",
}}>
{label}
</span>
);
}
export function ProjectShell({
function ProjectShellInner({
children,
workspace,
projectId,
projectName,
projectDescription,
projectStatus,
projectProgress,
createdAt,
updatedAt,
featureCount = 0,
}: ProjectShellProps) {
const pathname = usePathname();
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
const progress = projectProgress ?? 0;
const { data: session } = useSession();
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const activeSection =
pathname?.includes("/overview") ? "overview" :
pathname?.includes("/prd") ? "prd" :
pathname?.includes("/build") ? "build" :
pathname?.includes("/growth") ? "growth" :
pathname?.includes("/assist") ? "assist" :
pathname?.includes("/analytics") ? "analytics" :
"overview";
useEffect(() => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => setSavedPhases(d.phases ?? []))
.catch(() => {});
// Refresh every 10s while the user is chatting with Atlas
const interval = setInterval(() => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => setSavedPhases(d.phases ?? []))
.catch(() => {});
}, 10_000);
return () => clearInterval(interval);
}, [projectId]);
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const firstUnsavedIdx = DISCOVERY_PHASES.findIndex(p => !savedPhaseIds.has(p.id));
const userInitial = (
session?.user?.name?.[0] ?? session?.user?.email?.[0] ?? "?"
).toUpperCase();
return (
<>
<style>{`
@media (max-width: 768px) {
.vibn-left-sidebar { display: none !important; }
.vibn-right-panel { display: none !important; }
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
.vibn-project-header { padding: 12px 16px !important; }
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
}
@media (max-width: 480px) {
.vibn-tab-bar a { padding: 10px 10px !important; }
}
`}</style>
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
{/* Left sidebar */}
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
<VIBNSidebar workspace={workspace} />
</div>
<div style={{
display: "flex", flexDirection: "column",
height: "100dvh", overflow: "hidden",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: "var(--background)",
}}>
{/* Main column */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* Project header */}
<div className="vibn-project-header" style={{
padding: "18px 32px",
borderBottom: "1px solid #e8e4dc",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#fff",
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{
width: 34, height: 34, borderRadius: 9,
background: "#1a1a1a12",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
{projectName[0]?.toUpperCase() ?? "P"}
</span>
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
}}>
{projectName}
</h2>
<StatusTag status={projectStatus} />
</div>
{projectDescription && (
<p style={{
fontSize: "0.75rem", color: "#a09a90", marginTop: 1,
fontFamily: "Outfit, sans-serif",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
maxWidth: 400,
}}>
{projectDescription}
</p>
)}
</div>
</div>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "0.78rem", fontWeight: 500,
color: "#1a1a1a", background: "#f6f4f0",
padding: "6px 12px", borderRadius: 6,
}}>
{progress}%
</div>
</div>
{/* Tab bar */}
<div className="vibn-tab-bar" style={{
padding: "0 32px",
borderBottom: "1px solid #e8e4dc",
display: "flex",
background: "#fff",
flexShrink: 0,
}}>
{TABS.map((t) => (
<Link
key={t.id}
href={`/${workspace}/project/${projectId}/${t.path}`}
style={{
padding: "12px 18px",
fontSize: "0.8rem",
fontWeight: 500,
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
borderBottom: activeTab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
transition: "all 0.12s",
fontFamily: "Outfit, sans-serif",
textDecoration: "none",
display: "block",
whiteSpace: "nowrap",
}}
>
{t.label}
</Link>
))}
</div>
{/* Page content */}
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
{children}
</div>
</div>
{/* Right panel — hidden on design tab (design page has its own right panel) */}
<div className="vibn-right-panel" style={{
width: activeTab === "design" ? 0 : 230,
borderLeft: activeTab === "design" ? "none" : "1px solid #e8e4dc",
background: "#fff",
padding: activeTab === "design" ? 0 : "22px 18px",
overflow: "auto",
flexShrink: 0,
fontFamily: "Outfit, sans-serif",
display: activeTab === "design" ? "none" : undefined,
{/* ── Top bar ── */}
<header style={{
height: 48, flexShrink: 0,
display: "flex", alignItems: "stretch",
background: "var(--card)", borderBottom: "1px solid var(--border)",
zIndex: 10,
}}>
{/* Discovery phases */}
<SectionLabel>Discovery</SectionLabel>
{DISCOVERY_PHASES.map((phase, i) => {
const isDone = savedPhaseIds.has(phase.id);
const isActive = !isDone && i === firstUnsavedIdx;
return (
<div
key={phase.id}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 0",
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
}}
>
<div style={{
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.58rem", fontWeight: 700,
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
}}>
{isDone ? "✓" : isActive ? "→" : i + 1}
</div>
<span style={{
fontSize: "0.78rem",
fontWeight: isActive ? 600 : 400,
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
}}>
{phase.label}
</span>
{/* Logo + project name */}
<div style={{
display: "flex", alignItems: "center",
padding: "0 16px", gap: 9, flexShrink: 0,
borderRight: "1px solid var(--border)",
}}>
<Link
href={`/${workspace}/projects`}
style={{ display: "flex", alignItems: "center", textDecoration: "none", flexShrink: 0 }}
>
<div style={{ width: 22, height: 22, borderRadius: 6, overflow: "hidden" }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
);
})}
</Link>
<span style={{
fontSize: "0.82rem", fontWeight: 600, color: "var(--foreground)",
maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{projectName}
</span>
</div>
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Tab nav */}
<div style={{ flex: 1, display: "flex", alignItems: "center", padding: "0 12px", gap: 2 }}>
{SECTIONS.map(s => {
const isActive = activeSection === s.id;
return (
<Link
key={s.id}
href={`/${workspace}/project/${projectId}/${s.path}`}
style={{
padding: "5px 12px", borderRadius: 8,
fontSize: "0.8rem",
fontWeight: isActive ? 600 : 440,
color: isActive ? "var(--foreground)" : "var(--muted-foreground)",
background: isActive ? "var(--secondary)" : "transparent",
textDecoration: "none",
transition: "background 0.1s, color 0.1s",
whiteSpace: "nowrap",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--muted)"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{s.label}
</Link>
);
})}
{/* Captured data — summaries from saved phases */}
<SectionLabel>Captured</SectionLabel>
{savedPhases.length > 0 ? (
savedPhases.map((p) => (
<div key={p.phase} style={{ marginBottom: 14 }}>
<div style={{
fontSize: "0.62rem", color: "#2e7d32",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
}}>
<span></span><span>{p.title}</span>
</div>
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
{p.summary}
</div>
</div>
))
) : (
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
Atlas will capture key details here as you chat.
</p>
)}
{/* Spacer */}
<div style={{ flex: 1 }} />
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* User avatar */}
<button
onClick={() => signOut({ callbackUrl: "/auth" })}
title={`${session?.user?.name ?? session?.user?.email ?? "Account"} — Sign out`}
style={{
width: 28, height: 28, borderRadius: "50%",
background: "var(--secondary)", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700, color: "var(--muted-foreground)", flexShrink: 0,
}}
>
{userInitial}
</button>
</div>
</header>
{/* Project info */}
<SectionLabel>Project Info</SectionLabel>
{[
{ k: "Created", v: timeAgo(createdAt) },
{ k: "Last active", v: timeAgo(updatedAt) },
{ k: "Features", v: featureCount > 0 ? `${featureCount} defined` : "None yet" },
].map((item, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div style={{
fontSize: "0.62rem", color: "#b5b0a6",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600,
}}>
{item.k}
</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
</div>
))}
{/* ── Full-width content ── */}
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</div>
</div>
<Toaster position="top-center" />
</>
);
}
// Wrap in Suspense because useSearchParams requires it
export function ProjectShell(props: ProjectShellProps) {
return (
<Suspense fallback={null}>
<ProjectShellInner {...props} />
</Suspense>
);
}

View File

@@ -5,43 +5,48 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
interface Project {
interface TabItem {
id: string;
productName: string;
status?: string;
label: string;
path: string;
}
interface VIBNSidebarProps {
workspace: string;
tabs?: TabItem[];
activeTab?: string;
}
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 style={{
width: 7, height: 7, borderRadius: "50%",
background: color, display: "inline-block",
flexShrink: 0, animation: anim,
}} />
);
interface ProjectData {
id: string;
productName?: string;
name?: string;
status?: string;
}
// ── Main sidebar ─────────────────────────────────────────────────────────────
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
const COLLAPSED_W = 56;
const EXPANDED_W = 220;
const COLLAPSED_W = 52;
const EXPANDED_W = 216;
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
const pathname = usePathname();
const { data: session } = useSession();
const [projects, setProjects] = useState<Project[]>([]);
const [collapsed, setCollapsed] = useState(false);
const [mounted, setMounted] = useState(false);
// Restore collapse state from localStorage
// Project-specific data
const [project, setProject] = useState<ProjectData | null>(null);
// Global projects list (used when NOT inside a project)
const [projects, setProjects] = useState<Array<{ id: string; productName: string; status?: string }>>([]);
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
// Restore collapse state
useEffect(() => {
const stored = localStorage.getItem(COLLAPSED_KEY);
if (stored === "1") setCollapsed(true);
@@ -55,14 +60,25 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
});
};
// Fetch global projects list (for non-project pages)
useEffect(() => {
if (activeProjectId) return;
fetch("/api/projects")
.then((r) => r.json())
.then((d) => setProjects(d.projects ?? []))
.then(r => r.json())
.then(d => setProjects(d.projects ?? []))
.catch(() => {});
}, []);
}, [activeProjectId]);
// Fetch project-specific data when inside a project
useEffect(() => {
if (!activeProjectId) { setProject(null); return; }
fetch(`/api/projects/${activeProjectId}`)
.then(r => r.json())
.then(d => setProject(d.project ?? null))
.catch(() => {});
}, [activeProjectId]);
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
const isActivity = !activeProjectId && pathname?.includes("/activity");
const isSettings = !activeProjectId && pathname?.includes("/settings");
@@ -78,117 +94,85 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
?? "?";
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
// Don't animate on initial mount (avoid flash)
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
const base = `/${workspace}/project/${activeProjectId}`;
return (
<nav style={{
width: w,
height: "100vh",
background: "#fff",
borderRight: "1px solid #e8e4dc",
display: "flex",
flexDirection: "column",
fontFamily: "Outfit, sans-serif",
flexShrink: 0,
overflow: "hidden",
transition,
position: "relative",
width: w, height: "100vh",
background: "#fff", borderRight: "1px solid #e8e4dc",
display: "flex", flexDirection: "column",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
flexShrink: 0, overflow: "hidden",
transition, position: "relative",
}}>
{/* Logo + toggle row */}
{/* ── Logo + toggle ── */}
{collapsed ? (
/* Collapsed: logo centered, toggle below it */
<div style={{ flexShrink: 0 }}>
<div style={{ display: "flex", justifyContent: "center", padding: "16px 0 8px" }}>
<div style={{ display: "flex", justifyContent: "center", padding: "14px 0 6px" }}>
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
<div style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden" }}>
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden" }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
</Link>
</div>
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
<button
onClick={toggle}
title="Expand sidebar"
style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 28, height: 22, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.82rem", fontWeight: 700, transition: "background 0.12s, color 0.12s",
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
>
</button>
<button onClick={toggle} title="Expand sidebar" style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.8rem", fontWeight: 700,
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
></button>
</div>
</div>
) : (
/* Expanded: logo + name on left, toggle on right */
<div style={{ padding: "16px 12px 16px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
<div style={{ padding: "14px 10px 14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
<div style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
<span style={{ fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "Newsreader, serif", whiteSpace: "nowrap" }}>
<span style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "var(--font-lora), ui-serif, serif", whiteSpace: "nowrap" }}>
vibn
</span>
</Link>
<button
onClick={toggle}
title="Collapse sidebar"
style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 26, height: 24, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.82rem", fontWeight: 700, flexShrink: 0,
transition: "background 0.12s, color 0.12s",
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
>
</button>
<button onClick={toggle} title="Collapse sidebar" style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
></button>
</div>
)}
{/* Top nav */}
<div style={{ padding: collapsed ? "4px 8px" : "4px 10px", flexShrink: 0 }}>
{topNavItems.map((n) => {
{/* ── Top nav ── */}
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}>
{topNavItems.map(n => {
const isActive = n.id === "projects" ? isProjects
: n.id === "activity" ? isActivity
: n.id === "settings" ? isSettings
: false;
: isSettings;
return (
<Link
key={n.id}
href={n.href}
title={collapsed ? n.label : undefined}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9,
padding: collapsed ? "9px 0" : "8px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.82rem",
fontWeight: isActive ? 600 : 500,
transition: "all 0.12s",
textDecoration: "none",
}}
<Link key={n.id} href={n.href} title={collapsed ? n.label : undefined} style={{
width: "100%", display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8, padding: collapsed ? "8px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
transition: "background 0.12s", textDecoration: "none",
}}
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: collapsed ? "1rem" : "0.8rem",
opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45,
width: collapsed ? "auto" : 18,
textAlign: "center",
transition: "font-size 0.15s",
}}>
<span style={{ fontSize: collapsed ? "0.95rem" : "0.78rem", opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45, width: collapsed ? "auto" : 16, textAlign: "center" }}>
{n.icon}
</span>
{!collapsed && n.label}
@@ -197,91 +181,161 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
})}
</div>
<div style={{ height: 1, background: "#eae6de", margin: "10px 18px", flexShrink: 0 }} />
<div style={{ height: 1, background: "#eae6de", margin: "8px 14px", flexShrink: 0 }} />
{/* Projects list */}
<div style={{ padding: collapsed ? "2px 8px" : "2px 10px", flex: 1, overflow: "auto" }}>
{!collapsed && (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "6px 10px 8px",
}}>
Projects
{/* ── Lower section ── */}
<div style={{ flex: 1, overflow: "auto", paddingBottom: 8 }}>
{activeProjectId && project ? (
/* ── PROJECT VIEW: name + status + section tabs ── */
<>
{!collapsed && (
<>
<div style={{ padding: "6px 12px 8px" }}>
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{project.productName || project.name || "Project"}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
<span style={{
width: 6, height: 6, borderRadius: "50%", flexShrink: 0, display: "inline-block",
background: project.status === "live" ? "#2e7d32"
: project.status === "building" ? "#3d5afe"
: "#d4a04a",
}} />
<span style={{ fontSize: "0.68rem", color: "#8a8478" }}>
{project.status === "live" ? "Live" : project.status === "building" ? "Building" : "Defining"}
</span>
</div>
</div>
{tabs && tabs.length > 0 && (
<div style={{ padding: "2px 8px" }}>
{tabs.map(t => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
style={{
width: "100%", display: "flex", alignItems: "center",
padding: "7px 10px", borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
transition: "background 0.12s", textDecoration: "none",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{t.label}
</Link>
);
})}
</div>
)}
</>
)}
{collapsed && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 8, gap: 6 }}>
<span style={{
width: 7, height: 7, borderRadius: "50%", display: "inline-block",
background: project.status === "live" ? "#2e7d32"
: project.status === "building" ? "#3d5afe"
: "#d4a04a",
}} title={project.productName || project.name} />
{tabs && tabs.map(t => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
title={t.label}
style={{
width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center",
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#a09a90",
fontSize: "0.6rem", fontWeight: 700, textDecoration: "none",
textTransform: "uppercase", letterSpacing: "0.02em",
transition: "background 0.12s",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{t.label.slice(0, 2)}
</Link>
);
})}
</div>
)}
</>
) : (
/* ── GLOBAL VIEW: projects list ── */
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px" }}>
{!collapsed && (
<div style={{ fontSize: "0.58rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", padding: "6px 10px 8px" }}>
Projects
</div>
)}
{projects.map(p => {
const isActive = activeProjectId === p.id;
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
return (
<Link key={p.id} href={`/${workspace}/project/${p.id}/overview`}
title={collapsed ? p.productName : undefined}
style={{
width: "100%", display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, padding: collapsed ? "9px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: "#1a1a1a", fontSize: "0.8rem",
fontWeight: isActive ? 600 : 450,
transition: "background 0.12s", textDecoration: "none", overflow: "hidden",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0 }} />
{!collapsed && (
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.productName}
</span>
)}
</Link>
);
})}
</div>
)}
{projects.map((p) => {
const isActive = activeProjectId === p.id;
return (
<Link
key={p.id}
href={`/${workspace}/project/${p.id}/overview`}
title={collapsed ? p.productName : undefined}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9,
padding: collapsed ? "9px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: "#1a1a1a",
fontSize: "0.82rem",
fontWeight: isActive ? 600 : 450,
transition: "background 0.12s",
textDecoration: "none",
overflow: "hidden",
}}
>
<StatusDot status={p.status} />
{!collapsed && (
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.productName}
</span>
)}
</Link>
);
})}
</div>
{/* User footer */}
{/* ── User footer ── */}
<div style={{
padding: collapsed ? "12px 0" : "14px 18px",
padding: collapsed ? "10px 0" : "12px 14px",
borderTop: "1px solid #eae6de",
display: "flex",
alignItems: "center",
display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9,
flexShrink: 0,
gap: 9, flexShrink: 0,
}}>
<div
title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
<div title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
style={{
width: 28, height: 28, borderRadius: "50%",
width: 26, height: 26, borderRadius: "50%",
background: "#f0ece4", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: "0.72rem", fontWeight: 600,
justifyContent: "center", fontSize: "0.7rem", fontWeight: 600,
color: "#8a8478", flexShrink: 0, cursor: "default",
}}
>
}}>
{userInitial}
</div>
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
<div style={{ fontSize: "0.76rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
</div>
<button
onClick={() => signOut({ callbackUrl: "/auth" })}
style={{
background: "none", border: "none", padding: 0,
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
fontFamily: "Outfit, sans-serif",
}}
>
<button onClick={() => signOut({ callbackUrl: "/auth" })} style={{
background: "none", border: "none", padding: 0,
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
Sign out
</button>
</div>

View File

@@ -1,278 +1,6 @@
'use client';
"use client";
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
interface ProjectCreationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspace: string;
initialWorkspacePath?: string;
}
const PROJECT_TYPES = [
{ id: 'web-app', label: 'Web App', icon: '⬡', desc: 'SaaS product users log into — dashboards, accounts, core features' },
{ id: 'website', label: 'Website', icon: '◎', desc: 'Marketing site, landing page, or content-driven public site' },
{ id: 'marketplace', label: 'Marketplace', icon: '⇄', desc: 'Two-sided platform connecting buyers and sellers or providers' },
{ id: 'mobile', label: 'Mobile App', icon: '▢', desc: 'iOS and Android app — touch-first, native feel' },
{ id: 'internal', label: 'Internal Tool', icon: '◫', desc: 'Admin panel, ops dashboard, or business process tool' },
{ id: 'ai-product', label: 'AI Product', icon: '◈', desc: 'AI-native product — copilot, agent, or model-powered workflow' },
];
export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectCreationModalProps) {
const router = useRouter();
const [step, setStep] = useState<1 | 2>(1);
const [productName, setProductName] = useState('');
const [projectType, setProjectType] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setStep(1);
setProductName('');
setProjectType(null);
setLoading(false);
setTimeout(() => inputRef.current?.focus(), 80);
}
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onOpenChange]);
const handleCreate = async () => {
if (!productName.trim() || !projectType) return;
setLoading(true);
try {
const res = await fetch('/api/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectName: productName.trim(),
projectType,
slug: productName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
product: { name: productName.trim(), type: projectType },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || 'Failed to create project');
return;
}
const data = await res.json();
onOpenChange(false);
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error('Something went wrong');
} finally {
setLoading(false);
}
};
if (!open) return null;
return createPortal(
<>
{/* Backdrop */}
<div
onClick={() => onOpenChange(false)}
style={{
position: 'fixed', inset: 0, zIndex: 50,
background: 'rgba(26,26,26,0.35)',
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Modal */}
<div style={{
position: 'fixed', inset: 0, zIndex: 51,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24, pointerEvents: 'none',
}}>
<div
onClick={e => e.stopPropagation()}
style={{
background: '#fff', borderRadius: 14,
boxShadow: '0 8px 40px rgba(26,26,26,0.14)',
padding: '32px 36px',
width: '100%', maxWidth: step === 2 ? 560 : 460,
fontFamily: 'Outfit, sans-serif',
pointerEvents: 'all',
animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)',
transition: 'max-width 0.2s ease',
}}
>
<style>{`
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
@keyframes spin { to { transform:rotate(360deg); } }
`}</style>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{step === 2 && (
<button
onClick={() => setStep(1)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#a09a90', fontSize: '1rem', padding: '2px 4px',
borderRadius: 4, transition: 'color 0.12s', lineHeight: 1,
}}
onMouseEnter={e => (e.currentTarget.style.color = '#1a1a1a')}
onMouseLeave={e => (e.currentTarget.style.color = '#a09a90')}
>
</button>
)}
<div>
<h2 style={{ fontFamily: 'Newsreader, serif', fontSize: '1.3rem', fontWeight: 400, color: '#1a1a1a', marginBottom: 2 }}>
{step === 1 ? 'New project' : `What are you building?`}
</h2>
<p style={{ fontSize: '0.78rem', color: '#a09a90' }}>
{step === 1 ? 'Give your project a name to get started.' : `Choose the type that best fits "${productName}".`}
</p>
</div>
</div>
<button
onClick={() => onOpenChange(false)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#b5b0a6', fontSize: '1.1rem', lineHeight: 1,
padding: '2px 4px', borderRadius: 4, transition: 'color 0.12s', flexShrink: 0,
}}
onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')}
onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')}
>
×
</button>
</div>
{/* Step 1 — Name */}
{step === 1 && (
<div>
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#6b6560', marginBottom: 7, letterSpacing: '0.02em' }}>
Project name
</label>
<input
ref={inputRef}
type="text"
value={productName}
onChange={e => setProductName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && productName.trim()) setStep(2); }}
placeholder="e.g. Foxglove, Meridian, OpsAI…"
style={{
width: '100%', padding: '11px 14px', marginBottom: 16,
borderRadius: 8, border: '1px solid #e0dcd4',
background: '#faf8f5', fontSize: '0.9rem',
fontFamily: 'Outfit, sans-serif', color: '#1a1a1a',
outline: 'none', transition: 'border-color 0.12s',
boxSizing: 'border-box',
}}
onFocus={e => (e.currentTarget.style.borderColor = '#1a1a1a')}
onBlur={e => (e.currentTarget.style.borderColor = '#e0dcd4')}
/>
<button
onClick={() => { if (productName.trim()) setStep(2); }}
disabled={!productName.trim()}
style={{
width: '100%', padding: '12px',
borderRadius: 8, border: 'none',
background: productName.trim() ? '#1a1a1a' : '#e0dcd4',
color: productName.trim() ? '#fff' : '#b5b0a6',
fontSize: '0.88rem', fontWeight: 600,
fontFamily: 'Outfit, sans-serif',
cursor: productName.trim() ? 'pointer' : 'not-allowed',
transition: 'opacity 0.15s, background 0.15s',
}}
onMouseEnter={e => { if (productName.trim()) (e.currentTarget.style.opacity = '0.85'); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
>
Next
</button>
</div>
)}
{/* Step 2 — Project type */}
{step === 2 && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 20 }}>
{PROJECT_TYPES.map(type => {
const isSelected = projectType === type.id;
return (
<button
key={type.id}
onClick={() => setProjectType(type.id)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '14px 16px', borderRadius: 10, textAlign: 'left',
border: `1px solid ${isSelected ? '#1a1a1a' : '#e8e4dc'}`,
background: isSelected ? '#1a1a1a08' : '#fff',
boxShadow: isSelected ? '0 0 0 1px #1a1a1a' : '0 1px 2px #1a1a1a04',
cursor: 'pointer', transition: 'all 0.12s',
fontFamily: 'Outfit, sans-serif',
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#d0ccc4'); }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#e8e4dc'); }}
>
<div style={{
width: 30, height: 30, borderRadius: 7, flexShrink: 0,
background: isSelected ? '#1a1a1a' : '#f6f4f0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.95rem', color: isSelected ? '#fff' : '#8a8478',
}}>
{type.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '0.84rem', fontWeight: 600, color: '#1a1a1a', marginBottom: 2 }}>
{type.label}
</div>
<div style={{ fontSize: '0.71rem', color: '#8a8478', lineHeight: 1.45 }}>
{type.desc}
</div>
</div>
</button>
);
})}
</div>
<button
onClick={handleCreate}
disabled={!projectType || loading}
style={{
width: '100%', padding: '12px',
borderRadius: 8, border: 'none',
background: projectType && !loading ? '#1a1a1a' : '#e0dcd4',
color: projectType && !loading ? '#fff' : '#b5b0a6',
fontSize: '0.88rem', fontWeight: 600,
fontFamily: 'Outfit, sans-serif',
cursor: projectType && !loading ? 'pointer' : 'not-allowed',
transition: 'opacity 0.15s, background 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
onMouseEnter={e => { if (projectType && !loading) (e.currentTarget.style.opacity = '0.85'); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
>
{loading ? (
<>
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #fff4', borderTopColor: '#fff', animation: 'spin 0.7s linear infinite', display: 'inline-block' }} />
Creating
</>
) : (
`Create ${productName}`
)}
</button>
</div>
)}
</div>
</div>
</>,
document.body
);
}
// Re-export the new multi-step creation flow as a drop-in replacement
// for the original 2-step ProjectCreationModal.
export { CreateProjectFlow as ProjectCreationModal } from "./project-creation/CreateProjectFlow";
export type { CreationMode } from "./project-creation/CreateProjectFlow";

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [chatText, setChatText] = useState("");
const [loading, setLoading] = useState(false);
const canCreate = name.trim().length > 0 && chatText.trim().length > 20;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "chat-import",
sourceData: { chatText: chatText.trim() },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="⌁" label="Import Chats" tagline="You've been thinking"
accent="#2e5a4a" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What are you building?"
autoFocus
/>
<FieldLabel>Paste your chat history</FieldLabel>
<textarea
value={chatText}
onChange={e => setChatText(e.target.value)}
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nVibn will extract decisions, ideas, open questions, and architecture notes."}
rows={8}
style={{
width: "100%", padding: "12px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.55,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Extract & analyse
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const isValidUrl = repoUrl.trim().startsWith("http");
const canCreate = name.trim().length > 0 && isValidUrl;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "code-import",
sourceData: { repoUrl: repoUrl.trim(), pat: pat.trim() || undefined },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="⌘" label="Import Code" tagline="Already have a repo"
accent="#1a3a5c" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What is this project called?"
autoFocus
/>
<FieldLabel>Repository URL</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourorg/your-repo"
/>
<FieldLabel>
Personal Access Token{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(required for private repos)</span>
</FieldLabel>
<input
type="password"
value={pat}
onChange={e => setPat(e.target.value)}
placeholder="ghp_… or similar"
style={{
width: "100%", padding: "11px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
Vibn will clone your repo, read key files, and build a full architecture map tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Import & map
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { TypeSelector } from "./TypeSelector";
import { FreshIdeaSetup } from "./FreshIdeaSetup";
import { ChatImportSetup } from "./ChatImportSetup";
import { CodeImportSetup } from "./CodeImportSetup";
import { MigrateSetup } from "./MigrateSetup";
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
interface CreateProjectFlowProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspace: string;
}
type Step = "select-type" | "setup";
export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProjectFlowProps) {
const [step, setStep] = useState<Step>("setup");
const [mode, setMode] = useState<CreationMode | null>("fresh");
useEffect(() => {
if (open) {
setStep("setup");
setMode("fresh");
}
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onOpenChange(false); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onOpenChange]);
if (!open) return null;
const handleSelectType = (selected: CreationMode) => {
setMode(selected);
setStep("setup");
};
const handleBack = () => {
setStep("select-type");
setMode(null);
};
const setupProps = { workspace, onClose: () => onOpenChange(false), onBack: handleBack };
return createPortal(
<>
<style>{`
@keyframes vibn-fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes vibn-slideUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:translateY(0); } }
@keyframes vibn-spin { to { transform:rotate(360deg); } }
`}</style>
{/* Backdrop */}
<div
onClick={() => onOpenChange(false)}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(26,26,26,0.38)",
animation: "vibn-fadeIn 0.15s ease",
}}
/>
{/* Modal container */}
<div style={{
position: "fixed", inset: 0, zIndex: 51,
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24, pointerEvents: "none",
}}>
<div
onClick={e => e.stopPropagation()}
style={{
background: "#fff", borderRadius: 16,
boxShadow: "0 12px 48px rgba(26,26,26,0.16)",
width: "100%",
maxWidth: 520,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
pointerEvents: "all",
animation: "vibn-slideUp 0.18s cubic-bezier(0.4,0,0.2,1)",
transition: "max-width 0.2s ease",
overflow: "hidden",
}}
>
{step === "select-type" && (
<TypeSelector
onSelect={handleSelectType}
onClose={() => onOpenChange(false)}
/>
)}
{step === "setup" && mode === "fresh" && <FreshIdeaSetup {...setupProps} />}
{step === "setup" && mode === "chat-import" && <ChatImportSetup {...setupProps} />}
{step === "setup" && mode === "code-import" && <CodeImportSetup {...setupProps} />}
{step === "setup" && mode === "migration" && <MigrateSetup {...setupProps} />}
</div>
</div>
</>,
document.body
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const canCreate = name.trim().length > 0;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "fresh",
sourceData: {},
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ fontSize: "1.15rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-lora), ui-serif, serif" }}>
New project
</div>
<button
onClick={onClose}
style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "1.1rem", padding: "2px 6px", lineHeight: 1 }}
>×</button>
</div>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. Foxglove, Meridian, OpsAI…"
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
const HOSTING_OPTIONS = [
{ value: "", label: "Select hosting provider" },
{ value: "vercel", label: "Vercel" },
{ value: "aws", label: "AWS (EC2 / ECS / Elastic Beanstalk)" },
{ value: "heroku", label: "Heroku" },
{ value: "digitalocean", label: "DigitalOcean (Droplet / App Platform)" },
{ value: "gcp", label: "Google Cloud Platform" },
{ value: "azure", label: "Microsoft Azure" },
{ value: "railway", label: "Railway" },
{ value: "render", label: "Render" },
{ value: "netlify", label: "Netlify" },
{ value: "self-hosted", label: "Self-hosted / VPS" },
{ value: "other", label: "Other" },
];
export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [liveUrl, setLiveUrl] = useState("");
const [hosting, setHosting] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const isValidRepo = repoUrl.trim().startsWith("http");
const isValidLive = liveUrl.trim().startsWith("http");
const canCreate = name.trim().length > 0 && (isValidRepo || isValidLive);
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "migration",
githubRepoUrl: repoUrl.trim() || undefined,
githubToken: pat.trim() || undefined,
sourceData: {
liveUrl: liveUrl.trim() || undefined,
hosting: hosting || undefined,
},
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<SetupHeader
icon="⇢" label="Migrate Product" tagline="Move an existing product"
accent="#4a2a5a" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Product name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What is this product called?"
autoFocus
/>
<FieldLabel>
Repository URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(recommended)</span>
</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourorg/your-repo"
/>
<FieldLabel>
Live URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span>
</FieldLabel>
<TextInput
value={liveUrl}
onChange={setLiveUrl}
placeholder="https://yourproduct.com"
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 4 }}>
<div>
<FieldLabel>Hosting provider</FieldLabel>
<select
value={hosting}
onChange={e => setHosting(e.target.value)}
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.88rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90",
outline: "none", boxSizing: "border-box", appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23a09a90' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
}}
>
{HOSTING_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<FieldLabel>
PAT{" "}<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(private repos)</span>
</FieldLabel>
<input
type="password"
value={pat}
onChange={e => setPat(e.target.value)}
placeholder="ghp_…"
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start migration plan
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import type { CreationMode } from "./CreateProjectFlow";
interface TypeSelectorProps {
onSelect: (mode: CreationMode) => void;
onClose: () => void;
}
const ALL_FLOW_TYPES: {
id: CreationMode;
icon: string;
label: string;
tagline: string;
desc: string;
accent: string;
hidden?: boolean;
}[] = [
{
id: "fresh",
icon: "✦",
label: "Fresh Idea",
tagline: "Start from scratch",
desc: "Talk through your idea with Vibn. We'll explore it together and shape it into a full product plan.",
accent: "#4a3728",
},
{
id: "chat-import",
icon: "⌁",
label: "Import Chats",
tagline: "You've been thinking",
desc: "Paste conversations from ChatGPT or Claude. Vibn extracts your decisions, ideas, and open questions.",
accent: "#2e5a4a",
},
{
id: "code-import",
icon: "⌘",
label: "Import Code",
tagline: "Already have a repo",
desc: "Point Vibn at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
accent: "#1a3a5c",
hidden: true,
},
{
id: "migration",
icon: "⇢",
label: "Migrate Product",
tagline: "Move an existing product",
desc: "Bring your live product into the VIBN infrastructure. Vibn builds a safe, phased migration plan.",
accent: "#4a2a5a",
hidden: true,
},
];
const FLOW_TYPES = ALL_FLOW_TYPES.filter(t => !t.hidden);
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
return (
<div style={{ padding: "32px 36px 36px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div>
<h2 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.4rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 4,
}}>
Start a new project
</h2>
<p style={{ fontSize: "0.78rem", color: "#a09a90", margin: 0 }}>
How would you like to begin?
</p>
</div>
<button
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>
×
</button>
</div>
{/* Type cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 10 }}>
{FLOW_TYPES.map(type => (
<button
key={type.id}
onClick={() => onSelect(type.id)}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start",
gap: 0, padding: "20px", borderRadius: 12, textAlign: "left",
border: "1px solid #e8e4dc",
background: "#faf8f5",
cursor: "pointer",
transition: "all 0.14s",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
position: "relative",
overflow: "hidden",
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = "#d0ccc4";
e.currentTarget.style.background = "#fff";
e.currentTarget.style.boxShadow = "0 2px 12px rgba(26,26,26,0.07)";
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = "#e8e4dc";
e.currentTarget.style.background = "#faf8f5";
e.currentTarget.style.boxShadow = "none";
}}
>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 9, marginBottom: 14,
background: `${type.accent}10`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.1rem", color: type.accent,
}}>
{type.icon}
</div>
{/* Label + tagline */}
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 2 }}>
{type.label}
</div>
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: type.accent, letterSpacing: "0.03em", marginBottom: 8, textTransform: "uppercase" }}>
{type.tagline}
</div>
{/* Description */}
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
{type.desc}
</div>
{/* Arrow */}
<div style={{
position: "absolute", right: 16, bottom: 16,
fontSize: "0.85rem", color: "#c5c0b8",
}}>
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { ReactNode, CSSProperties } from "react";
export interface SetupProps {
workspace: string;
onClose: () => void;
onBack: () => void;
}
// Shared modal header
export function SetupHeader({
icon,
label,
tagline,
accent,
onBack,
onClose,
}: {
icon: string;
label: string;
tagline: string;
accent: string;
onBack: () => void;
onClose: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
onClick={onBack}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1rem", padding: "3px 5px",
borderRadius: 4, lineHeight: 1, flexShrink: 0,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>
</button>
<div>
<h2 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 3,
}}>
{label}
</h2>
<p style={{ fontSize: "0.72rem", fontWeight: 600, color: accent, textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
{tagline}
</p>
</div>
</div>
<button
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4, flexShrink: 0,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
>
×
</button>
</div>
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
{children}
</label>
);
}
export function TextInput({
value,
onChange,
placeholder,
onKeyDown,
autoFocus,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
inputRef?: React.RefObject<HTMLInputElement>;
}) {
const base: CSSProperties = {
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
};
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
style={base}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
);
}
export function PrimaryButton({
onClick,
disabled,
loading,
children,
}: {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
children: ReactNode;
}) {
const active = !disabled && !loading;
return (
<button
onClick={onClick}
disabled={!active}
style={{
width: "100%", padding: "12px",
borderRadius: 8, border: "none",
background: active ? "#1a1a1a" : "#e0dcd4",
color: active ? "#fff" : "#b5b0a6",
fontSize: "0.88rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: active ? "pointer" : "not-allowed",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
}}
onMouseEnter={e => { if (active) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={e => { e.currentTarget.style.opacity = "1"; }}
>
{loading ? (
<>
<span style={{ width: 14, height: 14, borderRadius: "50%", border: "2px solid #fff4", borderTopColor: "#fff", animation: "vibn-spin 0.7s linear infinite", display: "inline-block" }} />
Creating
</>
) : children}
</button>
);
}

View File

@@ -0,0 +1,330 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface AnalysisResult {
decisions: string[];
ideas: string[];
openQuestions: string[];
architecture: string[];
targetUsers: string[];
}
interface ChatImportMainProps {
projectId: string;
projectName: string;
sourceData?: { chatText?: string };
analysisResult?: AnalysisResult;
}
type Stage = "intake" | "extracting" | "review";
function EditableList({
label,
items,
accent,
onChange,
}: {
label: string;
items: string[];
accent: string;
onChange: (items: string[]) => void;
}) {
const handleEdit = (i: number, value: string) => {
const next = [...items];
next[i] = value;
onChange(next);
};
const handleDelete = (i: number) => {
onChange(items.filter((_, idx) => idx !== i));
};
const handleAdd = () => {
onChange([...items, ""]);
};
return (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: accent, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
{label}
</div>
{items.length === 0 && (
<p style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", margin: "0 0 6px" }}>
Nothing captured.
</p>
)}
{items.map((item, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 5 }}>
<input
type="text"
value={item}
onChange={e => handleEdit(i, e.target.value)}
style={{
flex: 1, padding: "7px 10px", borderRadius: 6,
border: "1px solid #e0dcd4", background: "#faf8f5",
fontSize: "0.81rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", outline: "none",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<button
onClick={() => handleDelete(i)}
style={{ background: "none", border: "none", cursor: "pointer", color: "#c5c0b8", fontSize: "0.85rem", padding: "4px 6px" }}
onMouseEnter={e => (e.currentTarget.style.color = "#e53e3e")}
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
>
×
</button>
</div>
))}
<button
onClick={handleAdd}
style={{
background: "none", border: "1px dashed #e0dcd4", cursor: "pointer",
borderRadius: 6, padding: "5px 10px", fontSize: "0.72rem", color: "#a09a90",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", width: "100%",
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
>
+ Add
</button>
</div>
);
}
export function ChatImportMain({
projectId,
projectName,
sourceData,
analysisResult: initialResult,
}: ChatImportMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const hasChatText = !!sourceData?.chatText;
const [stage, setStage] = useState<Stage>(
initialResult ? "review" : hasChatText ? "extracting" : "intake"
);
const [chatText, setChatText] = useState(sourceData?.chatText ?? "");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AnalysisResult>(
initialResult ?? { decisions: [], ideas: [], openQuestions: [], architecture: [], targetUsers: [] }
);
// Kick off extraction automatically if chatText is ready
useEffect(() => {
if (stage === "extracting") {
runExtraction();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const runExtraction = async () => {
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/analyze-chats`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatText }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Extraction failed");
setResult(data.analysisResult);
setStage("review");
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
setStage("intake");
}
};
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/prd`);
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/build`);
// ── Stage: intake ─────────────────────────────────────────────────────────
if (stage === "intake") {
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 640, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Paste your chat history
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} Atlas will extract decisions, ideas, architecture notes, and more.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<textarea
value={chatText}
onChange={e => setChatText(e.target.value)}
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nCopy the full conversation — Atlas handles the cleanup."}
rows={14}
style={{
width: "100%", padding: "14px 16px", marginBottom: 16,
borderRadius: 10, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.6,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<button
onClick={() => {
if (chatText.trim().length > 20) {
setStage("extracting");
}
}}
disabled={chatText.trim().length < 20}
style={{
width: "100%", padding: "13px",
borderRadius: 8, border: "none",
background: chatText.trim().length > 20 ? "#1a1a1a" : "#e0dcd4",
color: chatText.trim().length > 20 ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: chatText.trim().length > 20 ? "pointer" : "not-allowed",
}}
>
Extract insights
</button>
</div>
</div>
);
}
// ── Stage: extracting ─────────────────────────────────────────────────────
if (stage === "extracting") {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center" }}>
<div style={{
width: 48, height: 48, borderRadius: "50%",
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
animation: "vibn-chat-spin 0.8s linear infinite",
margin: "0 auto 20px",
}} />
<style>{`@keyframes vibn-chat-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>
Analysing your chats
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
Atlas is extracting decisions, ideas, and insights
</p>
</div>
</div>
);
}
// ── Stage: review ─────────────────────────────────────────────────────────
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
What Atlas found
</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
Review and edit the extracted insights for <strong>{projectName}</strong>. These will seed your PRD or MVP plan.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
{/* Left column */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Decisions made"
items={result.decisions}
accent="#1a3a5c"
onChange={items => setResult(r => ({ ...r, decisions: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Ideas & features"
items={result.ideas}
accent="#2e5a4a"
onChange={items => setResult(r => ({ ...r, ideas: items }))}
/>
</div>
</div>
{/* Right column */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Open questions"
items={result.openQuestions}
accent="#9a7b3a"
onChange={items => setResult(r => ({ ...r, openQuestions: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Architecture notes"
items={result.architecture}
accent="#4a3728"
onChange={items => setResult(r => ({ ...r, architecture: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Target users"
items={result.targetUsers}
accent="#4a2a5a"
onChange={items => setResult(r => ({ ...r, targetUsers: items }))}
/>
</div>
</div>
</div>
{/* Decision buttons */}
<div style={{
background: "#1a1a1a", borderRadius: 12, padding: "22px 24px",
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to move forward?</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Choose how you want to proceed with {projectName}.</div>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handlePRD}
style={{
padding: "11px 22px", borderRadius: 8, border: "none",
background: "#fff", color: "#1a1a1a",
fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Generate PRD
</button>
<button
onClick={handleMVP}
style={{
padding: "11px 22px", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
>
Plan MVP Test
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface ArchRow {
category: string;
item: string;
status: "found" | "partial" | "missing";
detail?: string;
}
interface AnalysisResult {
summary: string;
rows: ArchRow[];
suggestedSurfaces: string[];
}
interface CodeImportMainProps {
projectId: string;
projectName: string;
sourceData?: { repoUrl?: string };
analysisResult?: AnalysisResult;
creationStage?: string;
}
type Stage = "input" | "cloning" | "mapping" | "surfaces";
const STATUS_COLORS = {
found: { bg: "#f0fdf4", text: "#15803d", label: "Found" },
partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" },
missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" },
};
const CATEGORY_ORDER = [
"Tech Stack", "Infrastructure", "Database", "API Surface",
"Frontend", "Auth", "Third-party", "Missing / Gaps",
];
const PROGRESS_STEPS = [
{ key: "cloning", label: "Cloning repository" },
{ key: "reading", label: "Reading key files" },
{ key: "analyzing", label: "Mapping architecture" },
{ key: "done", label: "Analysis complete" },
];
export function CodeImportMain({
projectId,
projectName,
sourceData,
analysisResult: initialResult,
creationStage,
}: CodeImportMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const hasRepo = !!sourceData?.repoUrl;
const getInitialStage = (): Stage => {
if (initialResult) return "mapping";
if (creationStage === "surfaces") return "surfaces";
if (hasRepo) return "cloning";
return "input";
};
const [stage, setStage] = useState<Stage>(getInitialStage);
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
const [progressStep, setProgressStep] = useState<string>("cloning");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AnalysisResult | null>(initialResult ?? null);
const [confirmedSurfaces, setConfirmedSurfaces] = useState<string[]>(
initialResult?.suggestedSurfaces ?? []
);
// Kick off analysis when in cloning stage
useEffect(() => {
if (stage !== "cloning") return;
startAnalysis();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
// Poll for analysis status when cloning
useEffect(() => {
if (stage !== "cloning") return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
const data = await res.json();
setProgressStep(data.stage ?? "cloning");
if (data.stage === "done" && data.analysisResult) {
setResult(data.analysisResult);
setConfirmedSurfaces(data.analysisResult.suggestedSurfaces ?? []);
clearInterval(interval);
setStage("mapping");
}
} catch { /* keep polling */ }
}, 2500);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const startAnalysis = async () => {
setError(null);
try {
await fetch(`/api/projects/${projectId}/analyze-repo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoUrl }),
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to start analysis");
setStage("input");
}
};
const handleConfirmSurfaces = async () => {
try {
await fetch(`/api/projects/${projectId}/design-surfaces`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surfaces: confirmedSurfaces }),
});
router.push(`/${workspace}/project/${projectId}/design`);
} catch { /* navigate anyway */ }
};
const toggleSurface = (s: string) => {
setConfirmedSurfaces(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
);
};
// ── Stage: input ──────────────────────────────────────────────────────────
if (stage === "input") {
const isValid = repoUrl.trim().startsWith("http");
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Import your repository
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} paste a clone URL to map your existing stack.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Repository URL (HTTPS)
</label>
<input
type="text"
value={repoUrl}
onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/yourorg/your-repo"
style={{
width: "100%", padding: "12px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onKeyDown={e => { if (e.key === "Enter" && isValid) setStage("cloning"); }}
autoFocus
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
Atlas will clone and map your stack tech, database, auth, APIs, and what's missing for a complete go-to-market build.
</div>
<button
onClick={() => { if (isValid) setStage("cloning"); }}
disabled={!isValid}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: isValid ? "#1a1a1a" : "#e0dcd4",
color: isValid ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: isValid ? "pointer" : "not-allowed",
}}
>
Map this repo
</button>
</div>
</div>
);
}
// ── Stage: cloning ────────────────────────────────────────────────────────
if (stage === "cloning") {
const currentIdx = PROGRESS_STEPS.findIndex(s => s.key === progressStep);
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 400 }}>
<div style={{
width: 52, height: 52, borderRadius: "50%",
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
animation: "vibn-repo-spin 0.85s linear infinite",
margin: "0 auto 24px",
}} />
<style>{`@keyframes vibn-repo-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>
Mapping your codebase
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>
{repoUrl || sourceData?.repoUrl || "Repository"}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
{PROGRESS_STEPS.map((step, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%", flexShrink: 0,
background: done ? "#1a1a1a" : active ? "#f6f4f0" : "#f6f4f0",
border: active ? "2px solid #1a1a1a" : done ? "none" : "2px solid #e0dcd4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90",
}}>
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#1a1a1a", display: "block" }} /> : ""}
</div>
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>
{step.label}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Stage: mapping ────────────────────────────────────────────────────────
if (stage === "mapping" && result) {
const byCategory: Record<string, ArchRow[]> = {};
for (const row of result.rows) {
const cat = row.category || "Other";
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(row);
}
const categories = [
...CATEGORY_ORDER.filter(c => byCategory[c]),
...Object.keys(byCategory).filter(c => !CATEGORY_ORDER.includes(c)),
];
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Architecture map
</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 4px" }}>
{projectName} {result.summary}
</p>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
{categories.map((cat, catIdx) => (
<div key={cat}>
{catIdx > 0 && <div style={{ height: 1, background: "#f0ece4" }} />}
<div style={{ padding: "12px 20px", background: "#faf8f5", fontSize: "0.68rem", fontWeight: 700, color: "#6b6560", letterSpacing: "0.06em", textTransform: "uppercase" }}>
{cat}
</div>
{byCategory[cat].map((row, i) => {
const sc = STATUS_COLORS[row.status];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 20px", borderTop: "1px solid #f6f4f0" }}>
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>
{sc.label}
</div>
</div>
);
})}
</div>
))}
</div>
<button
onClick={() => setStage("surfaces")}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: "#1a1a1a", color: "#fff",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Choose what to build next
</button>
</div>
</div>
);
}
// ── Stage: surfaces ───────────────────────────────────────────────────────
const SURFACE_OPTIONS = [
{ id: "marketing", label: "Marketing Site", icon: "◎", desc: "Landing page, pricing, blog" },
{ id: "web-app", label: "Web App", icon: "⬡", desc: "Core SaaS product with auth" },
{ id: "admin", label: "Admin Panel", icon: "◫", desc: "Ops dashboard, content management" },
{ id: "api", label: "API Layer", icon: "⌁", desc: "REST/GraphQL endpoints" },
];
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
What should Atlas build?
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
Based on the gap analysis, Atlas suggests the surfaces below. Confirm or adjust.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 24 }}>
{SURFACE_OPTIONS.map(s => {
const selected = confirmedSurfaces.includes(s.id);
return (
<button
key={s.id}
onClick={() => toggleSurface(s.id)}
style={{
padding: "18px", borderRadius: 10, textAlign: "left",
border: `2px solid ${selected ? "#1a1a1a" : "#e8e4dc"}`,
background: selected ? "#1a1a1a08" : "#fff",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "all 0.12s",
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = "#d0ccc4"; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = "#e8e4dc"; }}
>
<div style={{ fontSize: "1.2rem", marginBottom: 8 }}>{s.icon}</div>
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 3 }}>{s.label}</div>
<div style={{ fontSize: "0.73rem", color: "#8a8478" }}>{s.desc}</div>
</button>
);
})}
</div>
<button
onClick={handleConfirmSurfaces}
disabled={confirmedSurfaces.length === 0}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: confirmedSurfaces.length > 0 ? "#1a1a1a" : "#e0dcd4",
color: confirmedSurfaces.length > 0 ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: confirmedSurfaces.length > 0 ? "pointer" : "not-allowed",
}}
>
Go to Design
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
"use client";
import { useEffect, useState } from "react";
import { AtlasChat } from "@/components/AtlasChat";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
const DISCOVERY_PHASES = [
"big_picture",
"users_personas",
"features_scope",
"business_model",
"screens_data",
"risks_questions",
];
// Maps discovery phases → the PRD sections they populate
const PRD_SECTIONS: { label: string; phase: string | null }[] = [
{ label: "Executive Summary", phase: "big_picture" },
{ label: "Problem Statement", phase: "big_picture" },
{ label: "Vision & Success Metrics", phase: "big_picture" },
{ label: "Users & Personas", phase: "users_personas" },
{ label: "User Flows", phase: "users_personas" },
{ label: "Feature Requirements", phase: "features_scope" },
{ label: "Screen Specs", phase: "features_scope" },
{ label: "Business Model", phase: "business_model" },
{ label: "Integrations & Dependencies", phase: "screens_data" },
{ label: "Non-Functional Reqs", phase: "features_scope" },
{ label: "Risks & Mitigations", phase: "risks_questions" },
{ label: "Open Questions", phase: "risks_questions" },
];
interface FreshIdeaMainProps {
projectId: string;
projectName: string;
}
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
const [allDone, setAllDone] = useState(false);
const [prdLoading, setPrdLoading] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [hasPrd, setHasPrd] = useState(false);
useEffect(() => {
// Check if PRD already exists on the project
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => { if (d.project?.prd) setHasPrd(true); })
.catch(() => {});
const poll = () => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => {
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
setSavedPhaseIds(ids);
const done = DISCOVERY_PHASES.every(id => ids.has(id));
setAllDone(done);
})
.catch(() => {});
};
poll();
const interval = setInterval(poll, 8_000);
return () => clearInterval(interval);
}, [projectId]);
const handleGeneratePRD = async () => {
if (prdLoading) return;
setPrdLoading(true);
try {
router.push(`/${workspace}/project/${projectId}/prd`);
} finally {
setPrdLoading(false);
}
};
const handleMVP = () => {
router.push(`/${workspace}/project/${projectId}/build`);
};
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
const completedSections = PRD_SECTIONS.filter(({ phase }) =>
phase === null ? allDone : savedPhaseIds.has(phase)
).length;
const totalSections = PRD_SECTIONS.length;
return (
<div style={{ height: "100%", display: "flex", flexDirection: "row", overflow: "hidden" }}>
{/* ── Left: Atlas chat ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{/* PRD ready notice — replaces the decision banner once PRD is saved */}
{hasPrd && (
<div style={{
background: "#1a1a1a", padding: "10px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0, borderBottom: "1px solid #333",
}}>
<div style={{ fontSize: "0.8rem", color: "#e8e4dc", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
PRD saved you can keep refining here or view the full document.
</div>
<Link
href={`/${workspace}/project/${projectId}/prd`}
style={{
padding: "6px 14px", borderRadius: 7,
background: "#fff", color: "#1a1a1a",
fontSize: "0.76rem", fontWeight: 600,
textDecoration: "none", flexShrink: 0,
}}
>
View PRD
</Link>
</div>
)}
{/* Decision banner — shown when all 6 phases are saved but PRD not yet generated */}
{allDone && !dismissed && !hasPrd && (
<div style={{
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
padding: "14px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0, flexWrap: "wrap",
borderBottom: "1px solid #333",
}}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 2 }}>
Discovery complete what&apos;s next?
</div>
<div style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
All 6 phases captured. Generate your PRD or jump into Build.
</div>
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
<button
onClick={handleGeneratePRD}
disabled={prdLoading}
style={{
padding: "8px 16px", borderRadius: 7, border: "none",
background: "#fff", color: "#1a1a1a",
fontSize: "0.8rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
transition: "opacity 0.12s",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
{prdLoading ? "Navigating…" : "Generate PRD →"}
</button>
<button
onClick={handleMVP}
style={{
padding: "8px 16px", borderRadius: 7,
border: "1px solid rgba(255,255,255,0.2)",
background: "transparent", color: "#fff",
fontSize: "0.8rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
>
Plan MVP
</button>
<button
onClick={() => setDismissed(true)}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#888", fontSize: "1rem", padding: "4px 6px",
}}
title="Dismiss"
>×</button>
</div>
</div>
)}
<AtlasChat projectId={projectId} projectName={projectName} />
</div>
{/* ── Right: PRD section tracker ── */}
<div style={{
width: 240, flexShrink: 0,
background: "#faf8f5",
borderLeft: "1px solid #e8e4dc",
display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
{/* Header */}
<div style={{
padding: "14px 16px 10px",
borderBottom: "1px solid #e8e4dc",
flexShrink: 0,
}}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#1a1a1a", letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
PRD Sections
</div>
{/* Progress bar */}
<div style={{ height: 3, background: "#e8e4dc", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", borderRadius: 99,
background: "#1a1a1a",
width: `${Math.round((completedSections / totalSections) * 100)}%`,
transition: "width 0.4s ease",
}} />
</div>
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 5 }}>
{completedSections} of {totalSections} sections complete
</div>
</div>
{/* Section list */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0" }}>
{PRD_SECTIONS.map(({ label, phase }) => {
const isDone = phase === null
? allDone // non-functional reqs generated when all done
: savedPhaseIds.has(phase);
return (
<div
key={label}
style={{
padding: "8px 16px",
display: "flex", alignItems: "flex-start", gap: 10,
}}
>
{/* Status dot */}
<div style={{
width: 8, height: 8, borderRadius: "50%", flexShrink: 0, marginTop: 4,
background: isDone ? "#1a1a1a" : "transparent",
border: isDone ? "none" : "1.5px solid #c8c4bc",
transition: "all 0.3s",
}} />
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: "0.78rem", fontWeight: isDone ? 600 : 400,
color: isDone ? "#1a1a1a" : "#6b6560",
lineHeight: 1.3,
}}>
{label}
</div>
{!isDone && (
<div style={{ fontSize: "0.65rem", color: "#a09a90", marginTop: 2, lineHeight: 1.3 }}>
Complete this phase in Vibn
</div>
)}
</div>
</div>
);
})}
</div>
{/* Footer CTA */}
{allDone && (
<div style={{ padding: "12px 16px", borderTop: "1px solid #e8e4dc", flexShrink: 0 }}>
<Link
href={`/${workspace}/project/${projectId}/prd`}
style={{
display: "block", textAlign: "center",
padding: "9px 0", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600,
textDecoration: "none",
}}
>
Generate PRD
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface MigrateMainProps {
projectId: string;
projectName: string;
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
analysisResult?: Record<string, unknown>;
migrationPlan?: string;
creationStage?: string;
}
type Stage = "input" | "auditing" | "review" | "planning" | "plan";
const HOSTING_OPTIONS = [
{ value: "", label: "Select hosting provider" },
{ value: "vercel", label: "Vercel" },
{ value: "aws", label: "AWS" },
{ value: "heroku", label: "Heroku" },
{ value: "digitalocean", label: "DigitalOcean" },
{ value: "gcp", label: "Google Cloud Platform" },
{ value: "azure", label: "Microsoft Azure" },
{ value: "railway", label: "Railway" },
{ value: "render", label: "Render" },
{ value: "netlify", label: "Netlify" },
{ value: "self-hosted", label: "Self-hosted / VPS" },
{ value: "other", label: "Other" },
];
function MarkdownRenderer({ md }: { md: string }) {
const lines = md.split('\n');
return (
<div style={{ fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem", color: "#1a1a1a", lineHeight: 1.7 }}>
{lines.map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, margin: "24px 0 10px", color: "#1a1a1a" }}>{line.slice(3)}</h2>;
if (line.startsWith('### ')) return <h3 key={i} style={{ fontSize: "0.88rem", fontWeight: 700, margin: "18px 0 6px", color: "#1a1a1a" }}>{line.slice(4)}</h3>;
if (line.startsWith('# ')) return <h1 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.5rem", fontWeight: 400, margin: "0 0 16px", color: "#1a1a1a" }}>{line.slice(2)}</h1>;
if (line.match(/^- \[ \] /)) return (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
<input type="checkbox" style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
<span>{line.slice(6)}</span>
</div>
);
if (line.match(/^- \[x\] /i)) return (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
<input type="checkbox" defaultChecked style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
<span style={{ textDecoration: "line-through", color: "#a09a90" }}>{line.slice(6)}</span>
</div>
);
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ paddingLeft: 16, marginBottom: 4 }}> {line.slice(2)}</div>;
if (line.startsWith('---')) return <hr key={i} style={{ border: "none", borderTop: "1px solid #e8e4dc", margin: "16px 0" }} />;
if (!line.trim()) return <div key={i} style={{ height: "0.6em" }} />;
// Bold inline
const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
seg.startsWith("**") && seg.endsWith("**")
? <strong key={j}>{seg.slice(2, -2)}</strong>
: <span key={j}>{seg}</span>
);
return <p key={i} style={{ margin: "0 0 4px" }}>{parts}</p>;
})}
</div>
);
}
export function MigrateMain({
projectId,
projectName,
sourceData,
analysisResult: initialAnalysis,
migrationPlan: initialPlan,
creationStage,
}: MigrateMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const getInitialStage = (): Stage => {
if (initialPlan) return "plan";
if (creationStage === "planning") return "planning";
if (creationStage === "review" || initialAnalysis) return "review";
if (sourceData?.repoUrl || sourceData?.liveUrl) return "auditing";
return "input";
};
const [stage, setStage] = useState<Stage>(getInitialStage);
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
const [liveUrl, setLiveUrl] = useState(sourceData?.liveUrl ?? "");
const [hosting, setHosting] = useState(sourceData?.hosting ?? "");
const [analysisResult, setAnalysisResult] = useState<Record<string, unknown> | null>(initialAnalysis ?? null);
const [migrationPlan, setMigrationPlan] = useState<string>(initialPlan ?? "");
const [progressStep, setProgressStep] = useState<string>("cloning");
const [error, setError] = useState<string | null>(null);
// Poll during audit
useEffect(() => {
if (stage !== "auditing") return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
const data = await res.json();
setProgressStep(data.stage ?? "cloning");
if (data.stage === "done" && data.analysisResult) {
setAnalysisResult(data.analysisResult);
clearInterval(interval);
setStage("review");
}
} catch { /* keep polling */ }
}, 2500);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const startAudit = async () => {
setError(null);
setStage("auditing");
if (repoUrl) {
try {
await fetch(`/api/projects/${projectId}/analyze-repo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoUrl, liveUrl, hosting }),
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to start audit");
setStage("input");
}
} else {
// No repo — just use live URL fingerprinting via generate-migration-plan directly
setStage("review");
setAnalysisResult({ summary: `Live product at ${liveUrl}`, rows: [], suggestedSurfaces: [] });
}
};
const startPlanning = async () => {
setStage("planning");
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/generate-migration-plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analysisResult, sourceData: { repoUrl, liveUrl, hosting } }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Planning failed");
setMigrationPlan(data.migrationPlan);
setStage("plan");
} catch (e) {
setError(e instanceof Error ? e.message : "Planning failed");
setStage("review");
}
};
// ── Stage: input ──────────────────────────────────────────────────────────
if (stage === "input") {
const canProceed = repoUrl.trim().startsWith("http") || liveUrl.trim().startsWith("http");
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Tell us about your product
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} Atlas will audit your current setup and build a safe migration plan.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Repository URL (recommended)
</label>
<input type="text" value={repoUrl} onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/yourorg/your-repo"
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")} autoFocus
/>
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Live URL (optional)
</label>
<input type="text" value={liveUrl} onChange={e => setLiveUrl(e.target.value)}
placeholder="https://yourproduct.com"
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Current hosting provider
</label>
<select value={hosting} onChange={e => setHosting(e.target.value)}
style={{ width: "100%", padding: "11px 14px", marginBottom: 20, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.88rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90", outline: "none", boxSizing: "border-box", appearance: "none" }}
>
{HOSTING_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Your existing product stays live throughout. Atlas duplicates, never deletes.
</div>
<button onClick={startAudit} disabled={!canProceed}
style={{ width: "100%", padding: "13px", borderRadius: 8, border: "none", background: canProceed ? "#1a1a1a" : "#e0dcd4", color: canProceed ? "#fff" : "#b5b0a6", fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: canProceed ? "pointer" : "not-allowed" }}
>
Start audit
</button>
</div>
</div>
);
}
// ── Stage: auditing ───────────────────────────────────────────────────────
if (stage === "auditing") {
const steps = [
{ key: "cloning", label: "Cloning repository" },
{ key: "reading", label: "Reading configuration" },
{ key: "analyzing", label: "Auditing infrastructure" },
{ key: "done", label: "Audit complete" },
];
const currentIdx = steps.findIndex(s => s.key === progressStep);
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 400 }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 24px" }} />
<style>{`@keyframes vibn-mig-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>Auditing your product</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>This is non-destructive your live product is untouched</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
{steps.map((step, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 22, height: 22, borderRadius: "50%", flexShrink: 0, background: done ? "#4a2a5a" : "#f6f4f0", border: active ? "2px solid #4a2a5a" : done ? "none" : "2px solid #e0dcd4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90" }}>
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4a2a5a", display: "block" }} /> : ""}
</div>
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>{step.label}</span>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Stage: review ─────────────────────────────────────────────────────────
if (stage === "review") {
const rows = (analysisResult?.rows as Array<{ category: string; item: string; status: string; detail?: string }>) ?? [];
const summary = (analysisResult?.summary as string) ?? '';
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Audit complete</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{summary || `${projectName} — review your current infrastructure below.`}</p>
</div>
{rows.length > 0 && (
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
{rows.map((row, i) => {
const colorMap = { found: { bg: "#f0fdf4", text: "#15803d", label: "Found" }, partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" }, missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" } };
const sc = colorMap[row.status as keyof typeof colorMap] ?? colorMap.found;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderTop: i > 0 ? "1px solid #f6f4f0" : "none" }}>
<div style={{ fontSize: "0.7rem", color: "#a09a90", width: 110, flexShrink: 0 }}>{row.category}</div>
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>{sc.label}</div>
</div>
);
})}
</div>
)}
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<div style={{ background: "#1a1a1a", borderRadius: 12, padding: "22px 24px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to build the migration plan?</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Atlas will generate a phased migration doc with Mirror, Validate, Cutover, and Decommission phases.</div>
</div>
<button onClick={startPlanning}
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#fff", color: "#1a1a1a", fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Generate plan
</button>
</div>
</div>
</div>
);
}
// ── Stage: planning ───────────────────────────────────────────────────────
if (stage === "planning") {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 20px" }} />
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>Generating migration plan</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>Atlas is designing a safe, phased migration strategy</p>
</div>
</div>
);
}
// ── Stage: plan ───────────────────────────────────────────────────────────
return (
<div style={{ height: "100%", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Non-destructive banner */}
<div style={{ background: "#4a2a5a12", borderBottom: "1px solid #4a2a5a30", padding: "12px 32px", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<span style={{ fontSize: "1rem" }}>🛡</span>
<div>
<span style={{ fontSize: "0.8rem", fontWeight: 700, color: "#4a2a5a" }}>Non-destructive migration </span>
<span style={{ fontSize: "0.8rem", color: "#6b6560" }}>your existing product stays live throughout every phase. Atlas duplicates, never deletes.</span>
</div>
</div>
<div style={{ padding: "32px 40px" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Migration Plan</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{projectName} four phased migration with rollback plan</p>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "28px 32px" }}>
<MarkdownRenderer md={migrationPlan} />
</div>
<div style={{ marginTop: 20, display: "flex", gap: 10 }}>
<button
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
>
Go to Design
</button>
<button
onClick={() => window.print()}
style={{ padding: "11px 22px", borderRadius: 8, border: "1px solid #e0dcd4", background: "#fff", color: "#6b6560", fontSize: "0.85rem", fontWeight: 500, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
>
Print / Export
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -40,11 +40,11 @@ function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps)
switch (node.status) {
case "built":
return <CheckCircle2 className="h-3 w-3 text-green-600" />;
return <CheckCircle2 className="h-3 w-3 text-primary" />;
case "in_progress":
return <Clock className="h-3 w-3 text-blue-600" />;
return <Clock className="h-3 w-3 text-muted-foreground" />;
case "missing":
return <Circle className="h-3 w-3 text-gray-400" />;
return <Circle className="h-3 w-3 text-muted-foreground/50" />;
}
};
@@ -53,11 +53,11 @@ function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps)
switch (node.status) {
case "built":
return "bg-green-50 hover:bg-green-100 border-l-2 border-l-green-500";
return "bg-secondary hover:bg-muted border-l-2 border-l-primary";
case "in_progress":
return "bg-blue-50 hover:bg-blue-100 border-l-2 border-l-blue-500";
return "bg-muted/40 hover:bg-muted border-l-2 border-l-border";
case "missing":
return "hover:bg-gray-100 border-l-2 border-l-transparent";
return "hover:bg-muted/30 border-l-2 border-l-transparent";
}
};
@@ -113,12 +113,12 @@ function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps)
{node.metadata && (
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
{node.metadata.sessionsCount && node.metadata.sessionsCount > 0 && (
<span className="text-[10px] text-blue-600 font-medium bg-blue-100 px-1.5 py-0.5 rounded">
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
{node.metadata.sessionsCount}s
</span>
)}
{node.metadata.commitsCount && node.metadata.commitsCount > 0 && (
<span className="text-[10px] text-green-600 font-medium bg-green-100 px-1.5 py-0.5 rounded">
<span className="text-[10px] font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{node.metadata.commitsCount}c
</span>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,50 @@
#!/bin/sh
set -e
echo "=== Syncing NextAuth schema ==="
# NOTE: Do NOT use --accept-data-loss — it drops tables not in the Prisma schema,
# which destroys fs_users, fs_projects etc. Use --skip-generate only.
npx prisma db push --skip-generate || echo "Prisma push failed (non-fatal — tables may already be correct)"
# Do NOT run `prisma db push` here. The Prisma schema only lists NextAuth tables; db push
# would try to DROP every other table (fs_*, agent_*, atlas_*, etc.) to match the schema.
# NextAuth tables are created below with IF NOT EXISTS (same DDL as /api/admin/migrate).
echo "=== Ensuring app tables exist ==="
echo "=== Ensuring database tables exist (idempotent SQL) ==="
node -e "
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
pool.query(\`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
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)
);
CREATE TABLE IF NOT EXISTS fs_users (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT,
@@ -53,6 +86,37 @@ pool.query(\`
messages JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
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 '[]'::jsonb,
changed_files JSONB NOT NULL DEFAULT '[]'::jsonb,
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);
\`).then(() => { console.log('App tables ready'); pool.end(); }).catch(e => { console.error('Table init error:', e.message); pool.end(); });
"

View File

@@ -1,14 +1,85 @@
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import { query } from "@/lib/db-postgres";
import { ensureWorkspaceForUser } from "@/lib/workspaces";
const prisma = new PrismaClient();
const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim();
const isLocalNextAuth =
nextAuthUrl.startsWith("http://localhost") ||
nextAuthUrl.startsWith("http://127.0.0.1") ||
(process.env.NODE_ENV === "development" && !nextAuthUrl);
/** Set in .env.local (server + client): one email for local dev bypass. */
const devLocalEmail = (process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? "").trim();
const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim();
const devLocalAuthEnabled =
process.env.NODE_ENV === "development" && devLocalEmail.length > 0;
function isLocalhostHost(host: string): boolean {
const h = host.split(":")[0]?.toLowerCase() ?? "";
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "[::1]" ||
h === "::1"
);
}
export const authOptions: NextAuthOptions = {
debug: process.env.NODE_ENV === "development",
adapter: PrismaAdapter(prisma),
providers: [
...(devLocalAuthEnabled
? [
CredentialsProvider({
id: "dev-local",
name: "Local dev",
credentials: {
password: { label: "Dev secret", type: "password" },
},
async authorize(credentials, req) {
const headers = (req as { headers?: Headers } | undefined)?.headers;
const host =
headers && typeof headers.get === "function"
? (headers.get("host") ?? "")
: "";
if (devLocalSecret) {
if ((credentials?.password ?? "") !== devLocalSecret) {
return null;
}
} else if (!isLocalhostHost(host)) {
return null;
}
const name =
(process.env.DEV_LOCAL_AUTH_NAME ?? "").trim() || "Local dev";
const user = await prisma.user.upsert({
where: { email: devLocalEmail },
create: {
email: devLocalEmail,
name,
emailVerified: new Date(),
},
update: { name },
});
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
]
: []),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
@@ -20,8 +91,8 @@ export const authOptions: NextAuthOptions = {
},
callbacks: {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if (session.user && "id" in user && user.id) {
(session.user as { id: string }).id = user.id;
}
return session;
},
@@ -42,16 +113,34 @@ export const authOptions: NextAuthOptions = {
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[user.email]
);
let fsUserId: string;
if (existing.length === 0) {
await query(
`INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb)`,
const inserted = await query<{ id: string }>(
`INSERT INTO fs_users (id, user_id, data)
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
RETURNING id`,
[user.id, data]
);
fsUserId = inserted[0].id;
} else {
await query(
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
[user.id, data, existing[0].id]
);
fsUserId = existing[0].id;
}
// Ensure a Vibn workspace exists for this user. We DO NOT
// provision Coolify/Gitea here — that happens lazily on first
// project create so signin stays fast and resilient to outages.
try {
await ensureWorkspaceForUser({
userId: fsUserId,
email: user.email,
displayName: user.name ?? null,
});
} catch (wsErr) {
console.error("[signIn] Failed to ensure workspace:", wsErr);
}
} catch (e) {
console.error("[signIn] Failed to upsert fs_user:", e);
@@ -66,13 +155,14 @@ export const authOptions: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET,
cookies: {
sessionToken: {
name: `__Secure-next-auth.session-token`,
// __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false
name: isLocalNextAuth ? "next-auth.session-token" : "__Secure-next-auth.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
domain: ".vibnai.com", // share across all subdomains (theia.vibnai.com, etc.)
secure: !isLocalNextAuth,
...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }),
},
},
},

46
lib/auth/secret-box.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Tiny AES-256-GCM wrapper for storing secrets (Gitea bot PATs, etc.)
* at rest in Postgres. Layout: base64( iv(12) || ciphertext || authTag(16) ).
*
* The key comes from VIBN_SECRETS_KEY. It must be base64 (32 bytes) OR
* any string we hash down to 32 bytes. We hash with SHA-256 so both
* forms work — rotating just means generating a new env value and
* re-provisioning workspaces.
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
const IV_BYTES = 12;
function getKey(): Buffer {
const raw = process.env.VIBN_SECRETS_KEY;
if (!raw || raw.length < 16) {
throw new Error(
'VIBN_SECRETS_KEY env var is required (>=16 chars) to encrypt workspace secrets'
);
}
// Normalize any input into a 32-byte key via SHA-256.
return createHash('sha256').update(raw).digest();
}
export function encryptSecret(plain: string): string {
const key = getKey();
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, enc, tag]).toString('base64');
}
export function decryptSecret(payload: string): string {
const buf = Buffer.from(payload, 'base64');
if (buf.length < IV_BYTES + 16) throw new Error('secret-box: payload too short');
const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(buf.length - 16);
const ciphertext = buf.subarray(IV_BYTES, buf.length - 16);
const key = getKey();
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return dec.toString('utf8');
}

View File

@@ -0,0 +1,42 @@
import { getServerSession } from "next-auth";
import type { Session } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
/** True when API routes should accept requests as the dev bypass user (next dev only). */
export function isProjectAuthBypassEnabled(): boolean {
return (
process.env.NODE_ENV === "development" &&
process.env.NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH === "true"
);
}
/** Email used for ownership checks when bypass is on; must match fs_users.data->>'email' for your projects. */
export function devBypassSessionEmail(): string | null {
const email = (
process.env.DEV_BYPASS_USER_EMAIL ||
process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ||
""
).trim();
return email || null;
}
/**
* Drop-in replacement for getServerSession(authOptions) on API routes.
* In development with NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH=true, returns a synthetic session
* so you can use the app without Google/cookies when DATABASE_URL works.
*/
export async function authSession(): Promise<Session | null> {
const session = await getServerSession(authOptions);
if (session?.user?.email) return session;
if (!isProjectAuthBypassEnabled()) return session;
const email = devBypassSessionEmail();
if (!email) return session;
return {
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
user: {
id: "dev-bypass",
email,
name: "Dev bypass",
},
};
}

247
lib/auth/workspace-auth.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* Workspace-scoped auth.
*
* Two principal types are accepted on `/api/...` routes:
* 1. NextAuth session (browser users) — `authSession()`
* 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`)
*
* Either way we resolve a `WorkspacePrincipal` that is scoped to one
* workspace. Routes that touch Coolify/Gitea/Theia must call
* `requireWorkspacePrincipal()` and use `principal.workspace` to fetch
* the right Coolify Project / Gitea org.
*/
import { createHash, randomBytes } from 'crypto';
import { NextResponse } from 'next/server';
import { authSession } from '@/lib/auth/session-server';
import { query, queryOne } from '@/lib/db-postgres';
import {
type VibnWorkspace,
getWorkspaceById,
getWorkspaceBySlug,
getWorkspaceByOwner,
userHasWorkspaceAccess,
} from '@/lib/workspaces';
const KEY_PREFIX = 'vibn_sk_';
const KEY_RANDOM_BYTES = 32; // 256-bit secret
export interface WorkspacePrincipal {
/** "session" = browser user; "api_key" = automated/AI client */
source: 'session' | 'api_key';
workspace: VibnWorkspace;
/** fs_users.id of the human ultimately responsible for this request */
userId: string;
/** When source = "api_key", which key id was used */
apiKeyId?: string;
}
/**
* Resolve the workspace principal from either a NextAuth session or a
* `Bearer vibn_sk_...` token. Optional `targetSlug` enforces that the
* principal is for the requested workspace.
*
* Returns:
* - principal on success
* - NextResponse on failure (401 / 403) — return it directly from the route
*/
export async function requireWorkspacePrincipal(
request: Request,
opts: { targetSlug?: string; targetId?: string } = {},
): Promise<WorkspacePrincipal | NextResponse> {
const apiKey = extractApiKey(request);
if (apiKey) {
const principal = await resolveApiKey(apiKey);
if (!principal) {
return NextResponse.json({ error: 'Invalid or revoked API key' }, { status: 401 });
}
if (!matchesTarget(principal.workspace, opts)) {
return NextResponse.json({ error: 'API key not authorized for this workspace' }, { status: 403 });
}
return principal;
}
// Fall through to NextAuth session
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userRow = await queryOne<{ id: string }>(
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[session.user.email]
);
if (!userRow) {
return NextResponse.json({ error: 'No fs_users row for session' }, { status: 401 });
}
let workspace: VibnWorkspace | null = null;
if (opts.targetSlug) workspace = await getWorkspaceBySlug(opts.targetSlug);
else if (opts.targetId) workspace = await getWorkspaceById(opts.targetId);
else workspace = await getWorkspaceByOwner(userRow.id);
if (!workspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
}
const ok = await userHasWorkspaceAccess(userRow.id, workspace.id);
if (!ok) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return { source: 'session', workspace, userId: userRow.id };
}
function matchesTarget(
workspace: VibnWorkspace,
opts: { targetSlug?: string; targetId?: string }
): boolean {
if (opts.targetSlug && workspace.slug !== opts.targetSlug) return false;
if (opts.targetId && workspace.id !== opts.targetId) return false;
return true;
}
function extractApiKey(request: Request): string | null {
const auth = request.headers.get('authorization');
if (!auth) return null;
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
if (!m) return null;
const token = m[1].trim();
if (!token.startsWith(KEY_PREFIX)) return null;
return token;
}
async function resolveApiKey(token: string): Promise<WorkspacePrincipal | null> {
const hash = hashKey(token);
const row = await queryOne<{
id: string;
workspace_id: string;
created_by: string;
revoked_at: Date | null;
}>(
`SELECT id, workspace_id, created_by, revoked_at
FROM vibn_workspace_api_keys
WHERE key_hash = $1
LIMIT 1`,
[hash]
);
if (!row || row.revoked_at) return null;
const workspace = await getWorkspaceById(row.workspace_id);
if (!workspace) return null;
// Touch last_used_at without blocking
void query(
`UPDATE vibn_workspace_api_keys SET last_used_at = now() WHERE id = $1`,
[row.id]
).catch(() => undefined);
return {
source: 'api_key',
workspace,
userId: row.created_by,
apiKeyId: row.id,
};
}
// ──────────────────────────────────────────────────
// Key minting + hashing
// ──────────────────────────────────────────────────
export interface MintedApiKey {
id: string;
/** Full plaintext key — shown once at creation, never stored. */
token: string;
prefix: string;
workspace_id: string;
name: string;
created_at: Date;
}
export async function mintWorkspaceApiKey(opts: {
workspaceId: string;
name: string;
createdBy: string;
scopes?: string[];
}): Promise<MintedApiKey> {
const random = randomBytes(KEY_RANDOM_BYTES).toString('base64url');
const token = `${KEY_PREFIX}${random}`;
const hash = hashKey(token);
const prefix = token.slice(0, 12); // e.g. "vibn_sk_AbCd"
const inserted = await query<{ id: string; created_at: Date }>(
`INSERT INTO vibn_workspace_api_keys
(workspace_id, name, key_prefix, key_hash, scopes, created_by)
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
RETURNING id, created_at`,
[
opts.workspaceId,
opts.name,
prefix,
hash,
JSON.stringify(opts.scopes ?? ['workspace:*']),
opts.createdBy,
]
);
return {
id: inserted[0].id,
token,
prefix,
workspace_id: opts.workspaceId,
name: opts.name,
created_at: inserted[0].created_at,
};
}
export async function listWorkspaceApiKeys(workspaceId: string): Promise<Array<{
id: string;
name: string;
prefix: string;
scopes: string[];
created_by: string;
last_used_at: Date | null;
revoked_at: Date | null;
created_at: Date;
}>> {
const rows = await query<{
id: string;
name: string;
key_prefix: string;
scopes: string[];
created_by: string;
last_used_at: Date | null;
revoked_at: Date | null;
created_at: Date;
}>(
`SELECT id, name, key_prefix, scopes, created_by, last_used_at, revoked_at, created_at
FROM vibn_workspace_api_keys
WHERE workspace_id = $1
ORDER BY created_at DESC`,
[workspaceId]
);
return rows.map(r => ({
id: r.id,
name: r.name,
prefix: r.key_prefix,
scopes: r.scopes,
created_by: r.created_by,
last_used_at: r.last_used_at,
revoked_at: r.revoked_at,
created_at: r.created_at,
}));
}
export async function revokeWorkspaceApiKey(workspaceId: string, keyId: string): Promise<boolean> {
const updated = await query<{ id: string }>(
`UPDATE vibn_workspace_api_keys
SET revoked_at = now()
WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL
RETURNING id`,
[keyId, workspaceId]
);
return updated.length > 0;
}
function hashKey(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

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