Compare commits

..

173 Commits

Author SHA1 Message Date
723cc5fdd1 fix(mcp): strip ANSI/control chars from compose startDiag for JSON safety
Made-with: Cursor
2026-04-23 20:25:26 -07:00
efb2082400 fix(mcp v2.4.2): apps.create reports started=true on partial sidecar failure
Coolify's `compose up -d` returns non-zero whenever any sidecar container
hits a `depends_on: condition: service_healthy` timeout. For slow-booting
apps like Twenty (where the worker waits ~3 min for twenty's healthcheck),
this caused apps.create to return started=false even when the primary
stack was running fine.

Now ensureServiceUp probes the host with `docker ps` after a non-zero
compose exit and returns started=true whenever any container is running,
surfacing the compose stderr in startDiag so agents can decide whether
to retry apps.containers.up later.

Made-with: Cursor
2026-04-23 20:12:03 -07:00
62cb77b5a7 feat(mcp v2.4.1): apps.containers.{up,ps} + auto-fallback for queued-start
Coolify's POST /services/{uuid}/start writes the rendered compose
files but its Laravel queue worker routinely fails to actually
invoke `docker compose up -d`. Until now agents had to SSH to
recover. For an MVP that promises "tell vibn what app you want,
get a URL", that's unacceptable.

- lib/coolify-compose.ts: composeUp/composeDown/composePs over SSH
  via a one-shot docker:cli container that bind-mounts the rendered
  compose dir (works around vibn-logs being in docker group but not
  having read access to /data/coolify/services).
- apps.create (template + composeRaw pathways) now uses
  ensureServiceUp which probes whether Coolify's queue actually
  spawned containers and falls back to direct docker compose up -d
  if not. Result includes startMethod for visibility.
- apps.containers.up / apps.containers.ps exposed as MCP tools for
  recovery scenarios and post-env-change recreations.
- Tenant safety: resolveAppOrService validates uuid against the
  caller's project before touching anything on the host.

Made-with: Cursor
2026-04-23 18:41:42 -07:00
e453e780cc feat(mcp v2.4): apps.create template pathway + apps.templates.{list,search}
Adds Coolify one-click template support — 320+ vetted apps deployable
in one MCP call (Twenty, n8n, Supabase, Ghost, etc).

- apps.create gains a 4th pathway: { template: "<slug>", ... }. Auto-
  rewrites the Coolify-assigned sslip URL to the workspace FQDN and
  applies user envs before starting.
- apps.templates.list / apps.templates.search expose the catalog so
  agents can discover slugs. Catalog is fetched from upstream GitHub
  and cached in-memory for 1h.
- lib/coolify.ts: + setServiceDomains, updateService, listService-
  Templates, searchServiceTemplates. Reuses existing createService.
- next.config.ts: externalize ssh2 + cpu-features from turbopack so
  `next build` can complete (native .node binaries can't be ESM-bundled).

Made-with: Cursor
2026-04-23 18:08:05 -07:00
7944db8ba4 fix(coolify): upsertServiceEnv falls back to PATCH on already-exists
Coolify auto-creates env entries (with empty values) for every ${VAR}
reference it finds in the compose YAML at service-creation time. So
POST /services/{uuid}/envs returns 'already exists' for any env we
try to set after creation. The fix is to fall back to PATCH on that
specific error, making the helper a true upsert.

Made-with: Cursor
2026-04-23 17:27:31 -07:00
5d4936346e fix: remove duplicate getService, fix project uuid check for services
Made-with: Cursor
2026-04-23 17:09:00 -07:00
040f0c6256 feat(mcp): proper Coolify Services support for composeRaw pathway
Coolify's /applications/dockercompose creates a Service (not Application)
with its own API surface. Wire it up correctly:

lib/coolify.ts
  - createDockerComposeApp returns { uuid, resourceType: 'service' }
  - Add startService, stopService, getService, listAllServices helpers
  - Add listServiceEnvs, upsertServiceEnv, bulkUpsertServiceEnvs for
    the /services/{uuid}/envs endpoint

app/api/mcp/route.ts
  - toolAppsList: includes Services (compose stacks) alongside Applications
  - toolAppsDeploy: falls back to /services/{uuid}/start for service UUIDs
  - toolAppsCreate composeRaw path: uses upsertServiceEnv + startService
    instead of Application deploy; notes that domain routing must be
    configured post-startup via SERVER_URL env

Made-with: Cursor
2026-04-23 17:02:21 -07:00
f27e572fdb fix: wait 2.5s before domain PATCH after dockercompose create (async creation)
Made-with: Cursor
2026-04-23 16:49:51 -07:00
8c8e39d102 fix: base64-encode docker_compose_raw for Coolify create endpoint
Made-with: Cursor
2026-04-23 16:43:33 -07:00
e09cad409e fix: remove autogenerate_domain from dockercompose create (not allowed)
Made-with: Cursor
2026-04-23 16:37:30 -07:00
1f37d4bc91 fix(coolify): remove disallowed fields from dockercompose create payload
Coolify's /applications/dockercompose endpoint rejects build_pack (it
hardcodes dockercompose), is_force_https_enabled, and
docker_compose_domains at creation time. Move those to a follow-up
PATCH call that runs immediately after creation.

Made-with: Cursor
2026-04-23 16:31:44 -07:00
6d71c63053 feat(mcp): apps.create image/composeRaw pathways + apps.volumes.list/wipe
Third-party apps (Twenty, Directus, Cal.com, Plane…) should never need
a Gitea repo. This adds two new apps.create pathways:

  image: "twentyhq/twenty:1.23.0"   → Coolify /applications/dockerimage
  composeRaw: "services:\n..."       → Coolify /applications/dockercompose

No repo is created, no git clone, no PAT embedding. Agents can fetch
the official docker-compose.yml and pass it inline, or just give an
image name. Pathway 1 (repo) is unchanged.

Also adds volume management tools so agents can self-recover from the
most common compose failure (stale DB volume blocking fresh migrations):

  apps.volumes.list { uuid }                        → list volumes + sizes
  apps.volumes.wipe { uuid, volume, confirm }       → stop containers,
                                                       rm volume, done

Both volume tools go through the same vibn-logs SSH channel. The wipe
tool requires confirm == volume name to prevent accidents and verifies
the volume belongs to the target app (uuid in name).

lib/coolify.ts: createDockerImageApp + createDockerComposeApp helpers,
  dockerimage added to CoolifyBuildPack union.
app/api/mcp/route.ts: resolveFqdn + applyEnvsAndDeploy extracted as
  shared helpers; toolAppsCreate now dispatches on image/composeRaw/repo.
  toolAppsVolumesList + toolAppsVolumesWipe added.
  sq() moved to module scope (shared by exec + volumes tools).
  Version bumped to 2.3.0.

Made-with: Cursor
2026-04-23 16:21:28 -07:00
8c83f8c490 feat(mcp): apps.exec — run one-shot commands in app containers
Companion to apps.logs. SSH to the Coolify host as vibn-logs, resolve
the target container by app uuid + service, and run the caller's
command through `docker exec ... sh -lc`. No TTY, no stdin — this is
the write-path sibling of apps.logs, purpose-built for migrations,
seeds, CLI invocations, and ad-hoc debugging.

- lib/coolify-containers.ts extracts container enumeration + service
  resolution into a shared helper used by both logs and exec.
- lib/coolify-exec.ts wraps docker exec with timeout (60s default,
  10-min cap), output byte cap (1 MB default, 5 MB cap), optional
  --user / --workdir, and structured audit logging of the command +
  target (never the output).
- app/api/mcp/route.ts wires `apps.exec` into the dispatcher and
  advertises it in the capabilities manifest.
- app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts exposes the same
  tool over REST for session-cookie callers.

Tenant safety: every entrypoint runs getApplicationInProject before
touching SSH, so an agent can only exec in apps belonging to their
workspace.

Made-with: Cursor
2026-04-23 14:18:49 -07:00
e766315ecd fix(apps): compose-aware domains; loud apps.update ignore list
Two live-test bugs surfaced while deploying Twenty CRM:

1. apps.domains.set silently 422'd on compose apps
   Coolify hard-rejects top-level `domains` for dockercompose build
   packs — they must use `docker_compose_domains` (per-service JSON).
   setApplicationDomains now detects build_pack (fetched via GET if
   not passed) and dispatches correctly. Default service is `server`
   (matches Twenty, Plane, Cal.com); override with `service` param.

2. apps.update silently dropped unrecognised fields
   Caller got `{ok:true}` even when zero fields persisted. This
   created false-positive "bug reports" (e.g. the user-reported
   "fqdn returns ok but doesn't persist" — fqdn was never forwarded
   at all). apps.update now returns:
     - applied:  fields that were forwarded to Coolify
     - ignored:  unknown fields (agent typos, stale field names)
     - rerouted: fields that belong to a different tool
                 (fqdn/domains → apps.domains.set,
                  git_repository → apps.rewire_git)
   400 when nothing applied, 200 with diagnostics otherwise.

Made-with: Cursor
2026-04-23 13:25:16 -07:00
d86f2bea03 feat(mcp): apps.logs — compose-aware runtime logs
Adds apps.logs MCP tool + session REST endpoint for tailing runtime
container logs. Unblocks cold-start debugging for agent-deployed
compose apps (Twenty, Cal.com, Plane, etc.) where Coolify's own
/applications/{uuid}/logs endpoint returns empty.

Architecture:
  - dockerfile / nixpacks / static apps → Coolify's REST logs API
  - dockercompose apps                  → SSH into Coolify host,
                                          `docker logs` per service

New SSH path uses a dedicated `vibn-logs` user (docker group, no
sudo, no pty, no port-forwarding, single ed25519 key). Private key
lives in COOLIFY_SSH_PRIVATE_KEY_B64 on the vibn-frontend Coolify
app; authorized_key is installed by scripts/setup-vibn-logs-user.sh
on the Coolify host.

Tool shape:
  params:   { uuid, service?, lines? (default 200, max 5000) }
  returns:  { uuid, buildPack, source: 'coolify_api'|'ssh_docker'|'empty',
              services: { [name]: { container, lines, bytes, logs, status? } },
              warnings: string[], truncated: boolean }

Made-with: Cursor
2026-04-23 13:21:52 -07:00
9959eaeeaa feat(mcp): expose storage.{describe,provision,inject_env} tools
The per-workspace GCS backend (bucket, service account, HMAC keys) was
already provisioned for P5.3 but wasn't reachable through MCP, so
agents using vibn_sk_* tokens couldn't actually use object storage.

Three new tools:
- storage.describe    → bucket, region, endpoint, access_key_id.
                        No secret in response.
- storage.provision   → idempotent ensureWorkspaceGcsProvisioned().
- storage.inject_env  → writes STORAGE_* (or user-chosen prefix) env
                        vars into a Coolify app. SECRET_ACCESS_KEY is
                        tagged is_shown_once so Coolify masks it in
                        the UI, and it never leaves our backend — the
                        agent kicks off injection, but the HMAC secret
                        is read from our DB and pushed directly to
                        Coolify.

Apps can then hit the bucket with any S3 SDK (aws-sdk, boto3, etc.)
using force_path_style=true and the standard endpoint.

Made-with: Cursor
2026-04-23 12:48:23 -07:00
fcd5d03894 fix(apps.create): clone via HTTPS+bot-PAT; activate bot users on creation
Coolify was failing all Gitea clones with "Permission denied (publickey)"
because the helper container's SSH hits git.vibnai.com:22 (Ubuntu host
sshd, which doesn't know Gitea keys), while Gitea's builtin SSH is on
host port 22222 (not publicly reachable).

Rather than fight the SSH topology, switch every Vibn-provisioned app
to clone over HTTPS with the workspace bot's PAT embedded in the URL.
The PAT is already stored encrypted per workspace and scoped to that
org, so this gives equivalent isolation with zero SSH dependency.

Changes:
- lib/naming.ts: add giteaHttpsUrl() + redactGiteaHttpsUrl(); mark
  giteaSshUrl() as deprecated-for-deploys with a comment.
- lib/coolify.ts: extend CreatePublicAppOpts with install/build/start
  commands, base_directory, dockerfile_location, docker_compose_location,
  manual_webhook_secret_gitea so it's at parity with the SSH variant.
- app/api/mcp/route.ts:
  - apps.create now uses createPublicApp(giteaHttpsUrl(...)) and pulls
    the bot PAT via getWorkspaceBotCredentials(). No more private-
    deploy-key path for new apps.
  - apps.update adds git_commit_sha + docker_compose_location to the
    whitelist.
  - New apps.rewire_git tool: re-points an app's git_repository at the
    canonical HTTPS+PAT URL. Unblocks older apps stuck on SSH URLs
    and provides a path for PAT rotation without rebuilding the app.
- lib/gitea.ts: createUser() now issues an immediate PATCH to set
  active: true. Gitea's admin-create endpoint creates users as inactive
  by default, and inactive users fail permission checks even though
  they're org members. GiteaUser gains optional `active` field.
- scripts/activate-workspace-bots.ts: idempotent backfill that flips
  active=true for any existing workspace bot that was created before
  this fix. Safe to re-run.
- AI_CAPABILITIES.md: document apps.rewire_git; clarify apps.create
  uses HTTPS+PAT (no SSH).

Already unblocked in prod for the mark workspace:
- vibn-bot-mark activated.
- twenty-crm's git_repository PATCHed to HTTPS+PAT form; git clone
  now succeeds (remaining unrelated error: docker-compose file path).

Made-with: Cursor
2026-04-23 12:21:00 -07:00
3192e0f7b9 fix(coolify): strip is_build_time from env writes; add reveal + GCS
Coolify v4's POST/PATCH /applications/{uuid}/envs only accepts key,
value, is_preview, is_literal, is_multiline, is_shown_once. Sending
is_build_time triggers a 422 "This field is not allowed." — it's now
a derived read-only flag (is_buildtime) computed from Dockerfile ARG
usage. Breaks agents trying to upsert env vars.

Three-layer fix so this can't regress:
  - lib/coolify.ts: COOLIFY_ENV_WRITE_FIELDS whitelist enforced at the
    network boundary, regardless of caller shape
  - app/api/workspaces/[slug]/apps/[uuid]/envs: stops forwarding the
    field; returns a deprecation warning when callers send it; GET
    reads both is_buildtime and is_build_time for version parity
  - app/api/mcp/route.ts: same treatment in the MCP dispatcher;
    AI_CAPABILITIES.md doc corrected

Also bundles (not related to the above):
  - Workspace API keys are now revealable from settings. New
    key_encrypted column stores AES-256-GCM(VIBN_SECRETS_KEY, token).
    POST /api/workspaces/[slug]/keys/[keyId]/reveal returns plaintext
    for session principals only; API-key principals cannot reveal
    siblings. Legacy keys stay valid for auth but can't reveal.
  - P5.3 Object storage: lib/gcp/storage.ts + lib/workspace-gcs.ts
    idempotently provision a per-workspace GCS bucket, service
    account, IAM binding and HMAC key. New POST /api/workspaces/
    [slug]/storage/buckets endpoint. Migration script + smoke test
    included. Proven end-to-end against prod master-ai-484822.

Made-with: Cursor
2026-04-23 11:46:50 -07:00
651ddf1e11 Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out:
- Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim)
- Delete app/api/projects/[projectId]/workspace/route.ts and
  app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning)
- Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts
- Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/
  theiaError from app/api/projects/create/route.ts response
- Remove Theia callbackUrl branch in app/auth/page.tsx
- Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx
- Drop theiaWorkspaceUrl from deployment/page.tsx Project type
- Strip Theia IDE line + theia-code-os from advisor + agent-chat
  context strings
- Scrub Theia mention from lib/auth/workspace-auth.ts comment

P5.1 (custom apex domains + DNS):
- lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS
  XML auth, Cloud DNS plumbing
- scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS +
  prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup

In-progress (Justine onboarding/build, MVP setup, agent telemetry):
- New (justine)/stories, project (home) layouts, mvp-setup, run, tasks
  routes + supporting components
- Project shell + sidebar + nav refactor for the Stackless palette
- Agent session API hardening (sessions, events, stream, approve,
  retry, stop) + atlas-chat, advisor, design-surfaces refresh
- New scripts/sync-db-url-from-coolify.mjs +
  scripts/prisma-db-push.mjs + docker-compose.local-db.yml for
  local Prisma workflows
- lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts
- Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel

Made-with: Cursor
2026-04-22 18:05:01 -07:00
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
24812df89b design-surfaces: explicit ::text cast on every query param
Add ::text cast to all $1/$2 parameters so PostgreSQL never needs
to infer types. Split SELECT and UPDATE into separate try/catch blocks
with distinct error labels so logs show exactly which query fails.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Scaffold renders cleanly with no UI chrome inside it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

@@ -58,7 +58,7 @@ export default function PricingPage() {
</Card>
{/* Pro Tier */}
<Card className="border-primary shadow-lg">
<Card className="relative border-primary shadow-lg">
<div className="absolute right-4 top-4 rounded-full bg-primary px-3 py-1 text-xs text-primary-foreground">
Popular
</div>

View File

@@ -0,0 +1 @@
export { default } from "../features/page";

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
"use client";
/**
* Project home scaffold.
*
* Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left,
* cream main area on the right. Used only for the project home page
* (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group
* with the ProjectShell tab nav instead.
*/
import { ReactNode } from "react";
import { useParams } from "next/navigation";
import { Toaster } from "sonner";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
export default function ProjectHomeLayout({ children }: { children: ReactNode }) {
const params = useParams();
const workspace = params.workspace as string;
return (
<>
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
<VIBNSidebar workspace={workspace} />
<main style={{ flex: 1, overflow: "auto" }}>
{children}
</main>
</div>
<ProjectAssociationPrompt workspace={workspace} />
<Toaster position="top-center" />
</>
);
}

View File

@@ -0,0 +1,696 @@
"use client";
/**
* Project home page.
*
* Sits between the projects list and the AI interview. Gives users two
* simplified entry tiles — Code (their Gitea repo) and Infrastructure
* (their Coolify deployment) — plus a quiet "Continue setup" link if
* the discovery interview isn't done.
*
* Styled to match the production "ink & parchment" design:
* Newsreader serif headings, Outfit sans body, warm beige borders,
* solid black CTAs. No indigo. No gradients.
*/
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import {
ArrowRight,
Code2,
ExternalLink,
FileText,
Folder,
Loader2,
Rocket,
} from "lucide-react";
// ── Design tokens (mirrors the prod ink & parchment palette) ─────────
const INK = {
fontSerif: '"Newsreader", "Lora", Georgia, serif',
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
ink: "#1a1a1a",
ink2: "#2c2c2a",
mid: "#5f5e5a",
muted: "#a09a90",
stone: "#b5b0a6",
border: "#e8e4dc",
borderHover: "#d0ccc4",
cardBg: "#fff",
pageBg: "#f7f4ee",
shadow: "0 1px 2px #1a1a1a05",
shadowHover: "0 2px 8px #1a1a1a0a",
iconWrapBg: "#1a1a1a08",
} as const;
interface ProjectSummary {
id: string;
productName?: string;
name?: string;
productVision?: string;
description?: string;
giteaRepo?: string;
giteaRepoUrl?: string;
stage?: "discovery" | "architecture" | "building" | "active";
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
discoveryPhase?: number;
progress?: number;
}
interface FileTreeItem {
name: string;
path: string;
type: "file" | "dir";
}
interface PreviewApp {
name: string;
url: string | null;
status: string;
}
export default function ProjectHomePage() {
const params = useParams();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const { status: authStatus } = useSession();
const [project, setProject] = useState<ProjectSummary | null>(null);
const [projectLoading, setProjectLoading] = useState(true);
const [files, setFiles] = useState<FileTreeItem[] | null>(null);
const [filesLoading, setFilesLoading] = useState(true);
const [apps, setApps] = useState<PreviewApp[]>([]);
const [appsLoading, setAppsLoading] = useState(true);
const ready = useMemo(
() => isClientDevProjectBypass() || authStatus === "authenticated",
[authStatus]
);
useEffect(() => {
if (!ready) {
if (authStatus === "unauthenticated") setProjectLoading(false);
return;
}
fetch(`/api/projects/${projectId}`, { credentials: "include" })
.then(r => r.json())
.then(d => setProject(d.project ?? null))
.catch(() => {})
.finally(() => setProjectLoading(false));
}, [ready, authStatus, projectId]);
useEffect(() => {
if (!ready) return;
fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" })
.then(r => (r.ok ? r.json() : null))
.then(d => {
if (d?.type === "dir" && Array.isArray(d.items)) {
setFiles(d.items as FileTreeItem[]);
} else {
setFiles([]);
}
})
.catch(() => setFiles([]))
.finally(() => setFilesLoading(false));
}, [ready, projectId]);
useEffect(() => {
if (!ready) return;
fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" })
.then(r => (r.ok ? r.json() : null))
.then(d => setApps(Array.isArray(d?.apps) ? d.apps : []))
.catch(() => {})
.finally(() => setAppsLoading(false));
}, [ready, projectId]);
const projectName = project?.productName || project?.name || "Untitled project";
const projectDesc = project?.productVision || project?.description;
const stage = project?.stage ?? "discovery";
const interviewIncomplete = stage === "discovery";
const liveApp = apps.find(a => a.url) ?? apps[0] ?? null;
if (projectLoading) {
return (
<div style={pageWrap}>
<div style={centeredFiller}>
<Loader2 className="animate-spin" size={22} style={{ color: INK.stone }} />
</div>
</div>
);
}
if (!project) {
return (
<div style={pageWrap}>
<div style={{ ...centeredFiller, color: INK.muted, fontSize: 14, fontFamily: INK.fontSans }}>
Project not found.
</div>
</div>
);
}
return (
<div style={pageWrap}>
<div style={pageInner}>
{/* ── Hero ─────────────────────────────────────────────── */}
<header style={heroStyle}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={eyebrow}>Project</div>
<h1 style={heroTitle}>{projectName}</h1>
{projectDesc && <p style={heroDesc}>{projectDesc}</p>}
</div>
<StagePill stage={stage} />
</header>
{/* ── Continue setup link (quiet, only when in discovery) ── */}
{interviewIncomplete && (
<Link
href={`/${workspace}/project/${projectId}/overview`}
style={continueRow}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<span style={continueDot} />
<div style={{ minWidth: 0 }}>
<div style={continueTitle}>Continue setup</div>
<div style={continueSub}>
Pick up the AI interview where you left off.
</div>
</div>
</div>
<ArrowRight size={16} style={{ color: INK.ink, flexShrink: 0 }} />
</Link>
)}
{/* ── Two big tiles ────────────────────────────────────── */}
<div style={tileGrid}>
<CodeTile
workspace={workspace}
projectId={projectId}
files={files}
loading={filesLoading}
giteaRepo={project.giteaRepo}
/>
<InfraTile
workspace={workspace}
projectId={projectId}
app={liveApp}
loading={appsLoading}
/>
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// Tiles
// ──────────────────────────────────────────────────────────────────────
function CodeTile({
workspace,
projectId,
files,
loading,
giteaRepo,
}: {
workspace: string;
projectId: string;
files: FileTreeItem[] | null;
loading: boolean;
giteaRepo?: string;
}) {
const items = files ?? [];
const dirCount = items.filter(i => i.type === "dir").length;
const fileCount = items.filter(i => i.type === "file").length;
const previewItems = items.slice(0, 6);
return (
<Link href={`/${workspace}/project/${projectId}/code`} style={tileLink}>
<article
style={tileCard}
onMouseEnter={hoverEnter}
onMouseLeave={hoverLeave}
>
<header style={tileHeader}>
<span style={tileIconWrap}>
<Code2 size={16} />
</span>
<div style={{ flex: 1 }}>
<h2 style={tileTitle}>Code</h2>
<p style={tileSubtitle}>What the AI is building, file by file.</p>
</div>
<ArrowRight size={14} style={{ color: INK.muted }} />
</header>
<div style={tileBody}>
{loading ? (
<TileLoader label="Reading repository…" />
) : items.length === 0 ? (
<TileEmpty
icon={<Folder size={18} />}
title="No files yet"
subtitle={
giteaRepo
? "Your repository is empty. The AI will commit the first files when you start building."
: "This project doesn't have a repository yet."
}
/>
) : (
<>
<div style={tileMetaRow}>
<Metric label="Folders" value={dirCount} />
<Metric label="Files" value={fileCount} />
</div>
<ul style={fileList}>
{previewItems.map(item => (
<li key={item.path} style={fileRow}>
<span style={fileIconWrap}>
{item.type === "dir" ? (
<Folder size={13} />
) : (
<FileText size={13} />
)}
</span>
<span style={fileName}>{item.name}</span>
<span style={fileType}>
{item.type === "dir" ? "folder" : ext(item.name)}
</span>
</li>
))}
</ul>
{items.length > previewItems.length && (
<div style={tileMore}>
+{items.length - previewItems.length} more
</div>
)}
</>
)}
</div>
</article>
</Link>
);
}
function InfraTile({
workspace,
projectId,
app,
loading,
}: {
workspace: string;
projectId: string;
app: PreviewApp | null;
loading: boolean;
}) {
const status = app?.status?.toLowerCase() ?? "unknown";
const isLive = !!app?.url && (status.includes("running") || status.includes("healthy"));
const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting");
return (
<Link href={`/${workspace}/project/${projectId}/infrastructure`} style={tileLink}>
<article
style={tileCard}
onMouseEnter={hoverEnter}
onMouseLeave={hoverLeave}
>
<header style={tileHeader}>
<span style={tileIconWrap}>
<Rocket size={16} />
</span>
<div style={{ flex: 1 }}>
<h2 style={tileTitle}>Infrastructure</h2>
<p style={tileSubtitle}>What's live and how it's running.</p>
</div>
<ArrowRight size={14} style={{ color: INK.muted }} />
</header>
<div style={tileBody}>
{loading ? (
<TileLoader label="Checking deployment…" />
) : !app ? (
<TileEmpty
icon={<Rocket size={18} />}
title="Nothing is live yet"
subtitle="The AI will deploy your project here once the build is ready."
/>
) : (
<>
<div style={tileMetaRow}>
<StatusBlock
color={isLive ? "#2e7d32" : isBuilding ? "#3d5afe" : "#9a7b3a"}
label={isLive ? "Live" : isBuilding ? "Building" : statusFriendly(status)}
/>
<Metric label="App" value={app.name} />
</div>
{app.url ? (
<div style={liveUrlRow}>
<span style={liveUrlLabel}>Live URL</span>
<span style={liveUrlValue}>{shortUrl(app.url)}</span>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={liveUrlOpen}
aria-label="Open live site"
>
<ExternalLink size={12} />
</a>
</div>
) : (
<div style={liveUrlRow}>
<span style={liveUrlLabel}>Status</span>
<span style={liveUrlValue}>{statusFriendly(status)}</span>
</div>
)}
</>
)}
</div>
</article>
</Link>
);
}
// ──────────────────────────────────────────────────────────────────────
// Small bits
// ──────────────────────────────────────────────────────────────────────
function StagePill({ stage }: { stage: string }) {
const map: Record<string, { label: string; color: string; bg: string }> = {
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" },
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
};
const s = map[stage] ?? map.discovery;
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
color: s.color, background: s.bg, fontFamily: INK.fontSans,
whiteSpace: "nowrap", flexShrink: 0,
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
{s.label}
</span>
);
}
function StatusBlock({ color, label }: { color: string; label: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={metricLabel}>Status</span>
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
{label}
</span>
</div>
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={metricLabel}>{label}</span>
<span style={{
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{value}
</span>
</div>
);
}
function TileLoader({ label }: { label: string }) {
return (
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, padding: "32px 0", color: INK.muted, fontSize: 13,
fontFamily: INK.fontSans,
}}>
<Loader2 className="animate-spin" size={14} /> {label}
</div>
);
}
function TileEmpty({
icon,
title,
subtitle,
}: {
icon: React.ReactNode;
title: string;
subtitle: string;
}) {
return (
<div style={{
padding: "28px 8px",
textAlign: "center",
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
fontFamily: INK.fontSans,
}}>
<span style={{ ...tileIconWrap, width: 38, height: 38 }}>{icon}</span>
<div style={{ fontSize: 13.5, fontWeight: 600, color: INK.ink }}>{title}</div>
<div style={{ fontSize: 12.5, color: INK.muted, lineHeight: 1.55, maxWidth: 280 }}>
{subtitle}
</div>
</div>
);
}
function statusFriendly(status: string): string {
if (!status || status === "unknown") return "Unknown";
return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
function ext(name: string): string {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1) : "file";
}
function shortUrl(url: string): string {
try {
const u = new URL(url);
return u.host + (u.pathname === "/" ? "" : u.pathname);
} catch {
return url;
}
}
function hoverEnter(e: React.MouseEvent<HTMLElement>) {
const el = e.currentTarget;
el.style.borderColor = INK.borderHover;
el.style.boxShadow = INK.shadowHover;
}
function hoverLeave(e: React.MouseEvent<HTMLElement>) {
const el = e.currentTarget;
el.style.borderColor = INK.border;
el.style.boxShadow = INK.shadow;
}
// ──────────────────────────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflow: "auto",
background: INK.pageBg,
fontFamily: INK.fontSans,
};
const pageInner: React.CSSProperties = {
maxWidth: 900,
margin: "0 auto",
padding: "44px 52px 64px",
display: "flex",
flexDirection: "column",
gap: 28,
};
const centeredFiller: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "center",
height: "100%", padding: 64,
};
const heroStyle: React.CSSProperties = {
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
gap: 24,
};
const eyebrow: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
fontFamily: INK.fontSans, marginBottom: 8,
};
const heroTitle: React.CSSProperties = {
fontFamily: INK.fontSerif,
fontSize: "1.9rem", fontWeight: 400,
color: INK.ink, letterSpacing: "-0.03em",
lineHeight: 1.15, margin: 0,
};
const heroDesc: React.CSSProperties = {
fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620,
lineHeight: 1.6, fontFamily: INK.fontSans,
};
const continueRow: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16,
background: INK.cardBg, border: `1px solid ${INK.border}`,
borderRadius: 10, padding: "14px 18px",
textDecoration: "none", color: "inherit",
boxShadow: INK.shadow,
fontFamily: INK.fontSans,
transition: "border-color 0.15s, box-shadow 0.15s",
};
const continueDot: React.CSSProperties = {
width: 7, height: 7, borderRadius: "50%",
background: "#d4a04a", flexShrink: 0,
};
const continueTitle: React.CSSProperties = {
fontSize: 13, fontWeight: 600, color: INK.ink,
};
const continueSub: React.CSSProperties = {
fontSize: 12, color: INK.muted, marginTop: 2,
};
const tileGrid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
gap: 14,
};
const tileLink: React.CSSProperties = {
textDecoration: "none", color: "inherit",
};
const tileCard: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 22,
display: "flex", flexDirection: "column", gap: 18,
minHeight: 280,
boxShadow: INK.shadow,
transition: "border-color 0.15s, box-shadow 0.15s",
fontFamily: INK.fontSans,
};
const tileHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 12,
};
const tileIconWrap: React.CSSProperties = {
width: 32, height: 32, borderRadius: 8,
background: INK.iconWrapBg, color: INK.ink,
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
};
const tileTitle: React.CSSProperties = {
fontFamily: INK.fontSerif,
fontSize: "1.05rem", fontWeight: 400,
color: INK.ink, letterSpacing: "-0.02em",
margin: 0, lineHeight: 1.2,
};
const tileSubtitle: React.CSSProperties = {
fontSize: 12, color: INK.muted, marginTop: 3,
fontFamily: INK.fontSans,
};
const tileBody: React.CSSProperties = {
display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0,
};
const tileMetaRow: React.CSSProperties = {
display: "flex", gap: 28,
};
const metricLabel: React.CSSProperties = {
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
textTransform: "uppercase", color: INK.muted,
fontFamily: INK.fontSans,
};
const fileList: React.CSSProperties = {
listStyle: "none", padding: 0, margin: 0,
display: "flex", flexDirection: "column",
border: `1px solid ${INK.border}`, borderRadius: 8,
overflow: "hidden",
background: "#fdfcfa",
};
const fileRow: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
padding: "8px 12px",
borderTop: `1px solid ${INK.border}`,
fontSize: 12.5, color: INK.ink,
};
const fileIconWrap: React.CSSProperties = {
color: INK.stone, display: "flex", alignItems: "center",
};
const fileName: React.CSSProperties = {
flex: 1, minWidth: 0, overflow: "hidden",
textOverflow: "ellipsis", whiteSpace: "nowrap",
fontFamily: INK.fontMono, fontSize: 12,
};
const fileType: React.CSSProperties = {
fontSize: 10, color: INK.stone, fontWeight: 500,
textTransform: "uppercase", letterSpacing: "0.08em",
flexShrink: 0, fontFamily: INK.fontSans,
};
const tileMore: React.CSSProperties = {
fontSize: 11.5, color: INK.muted, paddingLeft: 4,
fontFamily: INK.fontSans,
};
const liveUrlRow: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
padding: "10px 12px",
background: "#fdfcfa",
border: `1px solid ${INK.border}`,
borderRadius: 8,
};
const liveUrlLabel: React.CSSProperties = {
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
textTransform: "uppercase", color: INK.muted,
flexShrink: 0, fontFamily: INK.fontSans,
};
const liveUrlValue: React.CSSProperties = {
flex: 1, minWidth: 0,
fontSize: 12, color: INK.ink, fontWeight: 500,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
fontFamily: INK.fontMono,
};
const liveUrlOpen: React.CSSProperties = {
width: 24, height: 24, borderRadius: 6,
display: "flex", alignItems: "center", justifyContent: "center",
color: INK.ink, background: INK.cardBg,
border: `1px solid ${INK.border}`, flexShrink: 0,
textDecoration: "none",
};

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
export default function InfrastructurePage() {
return (
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
);
}

View File

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

View File

@@ -0,0 +1,81 @@
import { Plus_Jakarta_Sans } from "next/font/google";
import { ProjectShell } from "@/components/layout/project-shell";
import { query } from "@/lib/db-postgres";
const plusJakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-justine-jakarta",
});
interface ProjectData {
name: string;
description?: string;
status?: string;
progress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
async function getProjectData(projectId: string): Promise<ProjectData> {
try {
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length > 0) {
const { data, created_at, updated_at } = rows[0];
return {
name: data?.productName || data?.name || "Project",
description: data?.productVision || data?.description,
status: data?.status,
progress: data?.progress ?? 0,
discoveryPhase: data?.discoveryPhase ?? 0,
capturedData: data?.capturedData ?? {},
createdAt: created_at,
updatedAt: updated_at,
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
creationMode: data?.creationMode ?? "fresh",
};
}
} catch (error) {
console.error("Error fetching project:", error);
}
return { name: "Project" };
}
export default async function ProjectLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
const project = await getProjectData(projectId);
return (
<div className={plusJakarta.variable} style={{ height: "100%", minHeight: "100dvh" }}>
<ProjectShell
workspace={workspace}
projectId={projectId}
projectName={project.name}
projectDescription={project.description}
projectStatus={project.status}
projectProgress={project.progress}
discoveryPhase={project.discoveryPhase}
capturedData={project.capturedData}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
featureCount={project.featureCount}
creationMode={project.creationMode}
>
{children}
</ProjectShell>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
export default function MvpSetupArchitectPage() {
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
const base = `/${workspace}/project/${projectId}`;
return (
<MvpSetupStepPlaceholder
title="Architect"
subtitle="Lock in discovery — stack choices, surfaces, and what were shipping."
body="Use Task to run discovery phases and save answers. When youre ready, continue to Design."
primaryHref={`${base}/tasks`}
primaryLabel="Open Task"
nextHref={`${base}/mvp-setup/design`}
nextLabel="Continue to Design"
/>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import { useParams } from "next/navigation";
import { MvpSetupDescribeView } from "@/components/project-main/MvpSetupDescribeView";
export default function MvpSetupDescribePage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
return <MvpSetupDescribeView projectId={projectId} workspace={workspace} />;
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
export default function MvpSetupDesignPage() {
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
const base = `/${workspace}/project/${projectId}`;
return (
<MvpSetupStepPlaceholder
title="Design"
subtitle="Pick feel, color, and layout — well apply it across your product surfaces."
body="The full design studio lives on the Design tab. When it looks right, move on to how youll grow."
primaryHref={`${base}/design`}
primaryLabel="Open Design"
nextHref={`${base}/mvp-setup/website`}
nextLabel="Continue to Website"
/>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { MvpSetupLayoutClient } from "@/components/project-main/MvpSetupLayoutClient";
export default async function MvpSetupWizardLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
return (
<MvpSetupLayoutClient workspace={workspace} projectId={projectId}>
{children}
</MvpSetupLayoutClient>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
export default function MvpSetupWebsitePage() {
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
const base = `/${workspace}/project/${projectId}`;
return (
<MvpSetupStepPlaceholder
title="Website"
subtitle="Voice, topics, and marketing style — what people see before they sign up."
body="Tune growth messaging on the Grow tab. Then review everything and kick off your MVP build."
primaryHref={`${base}/growth`}
primaryLabel="Open Grow"
nextHref={`${base}/mvp-setup/launch`}
nextLabel="Review & launch"
/>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { BuildMvpJustineV2 } from "@/components/project-main/BuildMvpJustineV2";
import { JM } from "@/components/project-creation/modal-theme";
interface SurfaceEntry {
id: string;
lockedTheme?: string;
}
export default function MvpSetupLaunchPage() {
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
const router = useRouter();
const [productName, setProductName] = useState("Your product");
const [giteaRepo, setGiteaRepo] = useState<string | undefined>();
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
useEffect(() => {
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => {
const p = d.project;
if (p) {
setProductName(p.productName || p.name || "Your product");
setGiteaRepo(p.giteaRepo);
}
})
.catch(() => {});
fetch(`/api/projects/${projectId}/design-surfaces`)
.then(r => r.json())
.then(d => {
const ids: string[] = d.surfaces ?? [];
const themes: Record<string, string> = d.surfaceThemes ?? {};
setSurfaces(ids.map(id => ({ id, lockedTheme: themes[id] })));
})
.catch(() => {});
}, [projectId]);
const webappSurface = surfaces.find(s => s.id === "webapp");
const marketingSurface = surfaces.find(s => s.id === "marketing");
return (
<div style={{ height: "100%", overflow: "hidden", background: JM.inputBg }}>
<BuildMvpJustineV2
workspace={workspace}
projectId={projectId}
projectName={productName}
giteaRepo={giteaRepo}
accentLabel={webappSurface?.lockedTheme}
websiteStyle={marketingSurface?.lockedTheme}
onSwitchToPreview={() => {
router.push(`/${workspace}/project/${projectId}/build?section=preview`, { scroll: false });
}}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
/** Root: no sidebar — launch step uses full Justine chrome; wizard steps use (wizard)/layout. */
export default function MvpSetupRootLayout({ children }: { children: ReactNode }) {
return (
<div style={{ height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function MvpSetupIndexPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/mvp-setup/describe`);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import { Loader2 } from "lucide-react";
import { JM } from "@/components/project-creation/modal-theme";
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(() => {
const bypass = isClientDevProjectBypass();
if (!bypass && authStatus !== "authenticated") {
if (authStatus === "unauthenticated") setLoading(false);
return;
}
if (!bypass && authStatus === "loading") 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: JM.fontSans,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}>
<Loader2 style={{ width: 24, height: 24, color: JM.indigo }} className="animate-spin" />
</div>
);
}
if (!project) {
return (
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: "100%", fontFamily: JM.fontSans, color: JM.muted, fontSize: 14,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}>
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,11 @@
import { redirect } from "next/navigation";
/** Legacy URL — project work now lives under Tasks (PRD is the first task). */
export default async function PrdRedirectPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/tasks`);
}

View File

@@ -0,0 +1,5 @@
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
export default function RunPage() {
return <ProjectInfraPanel routeBase="run" navGroupLabel="Run" />;
}

View File

@@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface Project {
id: string;
productName: string;
productVision?: string;
giteaRepo?: string;
giteaRepoUrl?: string;
status?: string;
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
}}>
{children}
</div>
);
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>
{children}
</div>
);
}
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "22px", marginBottom: 12, boxShadow: "0 1px 2px #1a1a1a05", ...style,
}}>
{children}
</div>
);
}
export default function ProjectSettingsPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [productName, setProductName] = useState("");
const [productVision, setProductVision] = useState("");
const userInitial = session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
const userName = session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "You";
useEffect(() => {
fetch(`/api/projects/${projectId}`)
.then((r) => r.json())
.then((d) => {
const p = d.project;
setProject(p);
setProductName(p?.productName ?? "");
setProductVision(p?.productVision ?? "");
})
.catch(() => toast.error("Failed to load project"))
.finally(() => setLoading(false));
}, [projectId]);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productName, productVision }),
});
if (res.ok) {
toast.success("Saved");
setProject((p) => p ? { ...p, productName, productVision } : p);
} else {
toast.error("Failed to save");
}
} catch {
toast.error("An error occurred");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirmDelete) { setConfirmDelete(true); return; }
setDeleting(true);
try {
const res = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
if (res.ok) {
toast.success("Project deleted");
router.push(`/${workspace}/projects`);
} else {
toast.error("Failed to delete project");
}
} catch {
toast.error("An error occurred");
} finally {
setDeleting(false);
}
};
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>
);
}
return (
<div
className="vibn-enter"
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
<div style={{ maxWidth: 480 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
Project Settings
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
Configure {project?.productName ?? "this project"}
</p>
{/* General */}
<InfoCard>
<SectionLabel>General</SectionLabel>
<FieldLabel>Project name</FieldLabel>
<input
value={productName}
onChange={(e) => setProductName(e.target.value)}
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", marginBottom: 16, boxSizing: "border-box" }}
/>
<FieldLabel>Description</FieldLabel>
<textarea
value={productVision}
onChange={(e) => setProductVision(e.target.value)}
rows={3}
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", resize: "vertical", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
<button
onClick={handleSave}
disabled={saving}
style={{ padding: "8px 20px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: saving ? "not-allowed" : "pointer", opacity: saving ? 0.7 : 1, display: "flex", alignItems: "center", gap: 6 }}
>
{saving && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
{saving ? "Saving…" : "Save"}
</button>
</div>
</InfoCard>
{/* Repo */}
{project?.giteaRepoUrl && (
<InfoCard>
<SectionLabel>Repository</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a", fontWeight: 500 }}>{project.giteaRepo}</div>
</div>
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none" }}>
View
</a>
</div>
</InfoCard>
)}
{/* Collaborators */}
<InfoCard>
<SectionLabel>Collaborators</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 600, color: "#8a8478" }}>
{userInitial}
</div>
<span style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a" }}>{userName}</span>
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", background: "#f0ece4" }}>Owner</span>
</div>
<button
style={{ width: "100%", marginTop: 12, padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
onClick={() => toast.info("Team invites coming soon")}
>
+ Invite to project
</button>
</InfoCard>
{/* Export */}
<InfoCard>
<SectionLabel>Export</SectionLabel>
<p style={{ fontSize: "0.82rem", color: "#6b6560", marginBottom: 14, lineHeight: 1.6 }}>
Download your PRD or project data for external use.
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
onClick={() => toast.info("PDF export coming soon")}
>
Export PRD as PDF
</button>
<button
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
onClick={async () => {
const res = await fetch(`/api/projects/${projectId}`);
const data = await res.json();
const blob = new Blob([JSON.stringify(data.project, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `${productName.replace(/\s+/g, "-")}.json`; a.click();
URL.revokeObjectURL(url);
}}
>
Export as JSON
</button>
</div>
</InfoCard>
{/* Danger zone */}
<div style={{ background: "#fff", border: "1px solid #f5d5d5", borderRadius: 10, padding: "20px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#d32f2f" }}>Delete project</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{confirmDelete ? "Click again to confirm — this cannot be undone" : "This action cannot be undone"}
</div>
</div>
<button
onClick={handleDelete}
disabled={deleting}
style={{ padding: "6px 14px", borderRadius: 7, border: "1px solid #f5d5d5", background: confirmDelete ? "#d32f2f" : "#fff", color: confirmDelete ? "#fff" : "#d32f2f", fontSize: "0.72rem", fontWeight: 600, cursor: "pointer", transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6 }}
>
{deleting && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
{confirmDelete ? "Confirm Delete" : "Delete"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,507 @@
"use client";
import { useEffect, useState, type CSSProperties } 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: "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 TasksPage() {
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 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 tasks
</div>
);
}
const reqStatus = prd
? "Complete"
: doneCount > 0
? `In progress · ${doneCount}/${sections.length} sections`
: "Not started";
const archStatus = architecture
? "Complete"
: prd
? "Ready to generate"
: "Blocked — finish requirements first";
const taskCardBase: CSSProperties = {
flex: "1 1 240px",
maxWidth: 320,
textAlign: "left" as const,
padding: "14px 16px",
borderRadius: 10,
cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "border-color 0.12s, box-shadow 0.12s",
};
return (
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<header style={{ marginBottom: 24, maxWidth: 720 }}>
<h1 style={{
fontFamily: "var(--font-lora), ui-serif, serif",
fontSize: "1.35rem",
fontWeight: 500,
color: "#1a1a1a",
margin: "0 0 8px",
}}>
Tasks
</h1>
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.55, margin: 0 }}>
Work is tracked as taskssimilar in spirit to agent task boards like{" "}
<a href="https://github.com/777genius/claude_agent_teams_ui" target="_blank" rel="noopener noreferrer" style={{ color: "#4a4640" }}>
Claude Agent Teams UI
</a>
. Your <strong>product requirements (PRD)</strong> is the first task; technical architecture is the next once requirements are captured.
</p>
</header>
{/* Task selector — PRD is a task; architecture is a follow-on task */}
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => setActiveTab("prd")}
style={{
...taskCardBase,
border: activeTab === "prd" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
background: activeTab === "prd" ? "#faf8f5" : "#fff",
boxShadow: activeTab === "prd" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
}}
>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
Product requirements
</div>
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
PRD · {reqStatus}
</div>
</button>
<button
type="button"
onClick={() => architecture && setActiveTab("architecture")}
disabled={!architecture}
style={{
...taskCardBase,
border: activeTab === "architecture" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
background: activeTab === "architecture" ? "#faf8f5" : "#fff",
boxShadow: activeTab === "architecture" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
opacity: architecture ? 1 : 0.72,
cursor: architecture ? "pointer" : "not-allowed",
}}
>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
Technical architecture
</div>
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
{archStatus}
</div>
</button>
</div>
{/* Next step banner — PRD done but no architecture yet */}
{prd && !architecture && activeTab === "prd" && (
<div style={{
marginBottom: 24, padding: "18px 22px",
background: "#1a1a1a", borderRadius: 10,
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
Next: Generate technical architecture
</div>
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
</div>
{archError && (
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}> {archError}</div>
)}
</div>
<button
onClick={handleGenerateArchitecture}
disabled={archGenerating}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: archGenerating ? "#4a4640" : "#fff",
color: archGenerating ? "#a09a90" : "#1a1a1a",
fontSize: "0.82rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: archGenerating ? "default" : "pointer",
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
transition: "opacity 0.15s",
}}
>
{archGenerating && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #60606040", borderTopColor: "#a09a90",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
</button>
</div>
)}
{/* Architecture tab */}
{activeTab === "architecture" && architecture && (
<ArchitectureView arch={architecture} />
)}
{/* PRD tab — finalized */}
{activeTab === "prd" && prd && (
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Product Requirements
</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
PRD complete
</span>
</div>
<div style={{
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
padding: "28px 32px", lineHeight: 1.8,
fontSize: "0.88rem", color: "#2a2824",
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{prd}
</div>
</div>
)}
{/* PRD tab — section progress (no finalized PRD yet) */}
{activeTab === "prd" && !prd && (
/* ── Section progress view ── */
<div style={{ maxWidth: 680 }}>
{/* Progress bar */}
<div style={{
display: "flex", alignItems: "center", gap: 16,
padding: "16px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
}}>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
}}>
{totalPct}%
</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
<div style={{
height: "100%", borderRadius: 2,
width: `${totalPct}%`, background: "#1a1a1a",
transition: "width 0.6s ease",
}} />
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{doneCount}/{sections.length} sections
</span>
</div>
{/* Sections */}
{sections.map((s, i) => (
<div
key={s.id}
style={{
padding: "14px 18px", marginBottom: 6,
background: "#fff", borderRadius: 10,
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
animationDelay: `${i * 0.04}s`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Status icon */}
<div style={{
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700,
color: s.isDone ? "#2e7d32" : "#c5c0b8",
}}>
{s.isDone ? "✓" : "○"}
</div>
<span style={{
flex: 1, fontSize: "0.84rem",
color: s.isDone ? "#1a1a1a" : "#a09a90",
fontWeight: s.isDone ? 500 : 400,
}}>
{s.label}
</span>
{s.isDone && s.savedPhase && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
}}>
saved
</span>
)}
{!s.isDone && !s.phaseId && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#b5b0a6", padding: "2px 7px",
}}>
generated
</span>
)}
</div>
{/* Expandable phase data */}
{s.isDone && s.savedPhase && (
<PhaseDataCard phase={s.savedPhase} />
)}
{/* Pending hint */}
{!s.isDone && (
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
{s.phaseId
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
: "Will be generated when PRD is finalized"}
</div>
)}
</div>
))}
{doneCount === 0 && (
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
Continue chatting with Vibn saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -1,179 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart3, DollarSign, TrendingUp, Zap } from "lucide-react";
export default async function AnalyticsPage({
params,
}: {
params: { projectId: string };
}) {
return (
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="border-b bg-card/50 px-6 py-4">
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="text-sm text-muted-foreground">
Cost analysis, token usage, and performance metrics
</p>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-6xl space-y-6">
{/* Key Metrics */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$12.50</div>
<p className="text-xs text-muted-foreground">
<TrendingUp className="mr-1 inline h-3 w-3" />
+8% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tokens Used</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2.5M</div>
<p className="text-xs text-muted-foreground">Across all sessions</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Cost/Session</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$0.30</div>
<p className="text-xs text-muted-foreground">Per coding session</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cost/Feature</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$1.56</div>
<p className="text-xs text-muted-foreground">Average per feature</p>
</CardContent>
</Card>
</div>
{/* Detailed Analytics */}
<Tabs defaultValue="costs" className="space-y-4">
<TabsList>
<TabsTrigger value="costs">Costs</TabsTrigger>
<TabsTrigger value="tokens">Tokens</TabsTrigger>
<TabsTrigger value="performance">Performance</TabsTrigger>
</TabsList>
<TabsContent value="costs" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Cost Breakdown</CardTitle>
<CardDescription>
AI usage costs over time
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
Cost chart visualization coming soon
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost by Model</CardTitle>
<CardDescription>
Breakdown by AI model used
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ model: "Claude Sonnet 4", cost: "$8.20", percentage: 66 },
{ model: "GPT-4", cost: "$3.10", percentage: 25 },
{ model: "Gemini Pro", cost: "$1.20", percentage: 9 },
].map((item, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{item.model}</span>
<span className="text-muted-foreground">{item.cost}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tokens" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Token Usage</CardTitle>
<CardDescription>
Token consumption over time
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
Token usage chart coming soon
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Development Velocity</CardTitle>
<CardDescription>
Features completed over time
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
Velocity metrics coming soon
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,69 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Server } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
// Mock project data
const MOCK_PROJECT = {
id: "1",
name: "AI Proxy",
emoji: "🤖",
};
interface PageProps {
params: Promise<{ projectId: string }>;
}
export default async function DeploymentPage({ params }: PageProps) {
const { projectId } = await params;
return (
<>
<PageHeader
projectId={projectId}
projectName={MOCK_PROJECT.name}
projectEmoji={MOCK_PROJECT.emoji}
pageName="Deployment"
/>
<div className="flex-1 overflow-auto">
<div className="container max-w-7xl py-6 space-y-6">
{/* Hero Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>Deployment</CardTitle>
<CardDescription>
Manage deployments, monitor environments, and track releases
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 rounded-full bg-muted p-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-medium text-lg mb-2">Coming Soon</h3>
<p className="text-sm text-muted-foreground max-w-md">
Connect your hosting platforms to manage deployments, view logs,
and monitor your application&apos;s health across all environments.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

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

View File

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

View File

@@ -1,457 +0,0 @@
"use client";
import { use, useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Palette,
Plus,
MessageSquare,
History,
Loader2,
CheckCircle2,
Circle,
Clock,
Sparkles,
ExternalLink,
} from "lucide-react";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { toast } from "sonner";
import { TreeView, TreeNode } from "@/components/ui/tree-view";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
interface WorkItem {
id: string;
title: string;
path: string;
status: "built" | "in_progress" | "missing";
state?: "draft" | "final";
category: string;
priority: string;
startDate: string | null;
endDate: string | null;
sessionsCount: number;
commitsCount: number;
estimatedCost?: number;
requirements: Array<{
id: number;
text: string;
status: string;
}>;
versionCount?: number;
messageCount?: number;
}
export default function DesignPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = use(params);
// Helper function to count nodes by status recursively
const countNodesByStatus = (node: TreeNode, status: string): number => {
let count = node.status === status ? 1 : 0;
if (node.children) {
count += node.children.reduce((acc, child) => acc + countNodesByStatus(child, status), 0);
}
return count;
};
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [loading, setLoading] = useState(true);
const [filterState, setFilterState] = useState<"all" | "draft" | "final">("all");
const [selectedItem, setSelectedItem] = useState<WorkItem | null>(null);
const [selectedTreeNodeId, setSelectedTreeNodeId] = useState<string | null>(null);
// Sample tree structure - will be populated from AI-generated data
const [treeData] = useState<TreeNode[]>([
{
id: "navigation",
label: "Navigation",
children: [
{
id: "sidebar",
label: "Sidebar",
status: "in_progress",
children: [
{ id: "dashboard", label: "Dashboard", status: "built", metadata: { sessionsCount: 12, commitsCount: 5 } },
{ id: "projects", label: "Projects", status: "in_progress", metadata: { sessionsCount: 8, commitsCount: 3 } },
{ id: "account", label: "Account", status: "missing" },
],
},
{
id: "topnav",
label: "Top Nav",
status: "built",
children: [
{ id: "search", label: "Search", status: "built", metadata: { sessionsCount: 6, commitsCount: 2 } },
{ id: "notifications", label: "Notifications", status: "built", metadata: { sessionsCount: 4, commitsCount: 1 } },
],
},
],
},
{
id: "site",
label: "Site",
children: [
{
id: "home",
label: "Home",
status: "built",
children: [
{ id: "hero", label: "Hero", status: "built", metadata: { sessionsCount: 10, commitsCount: 4 } },
{ id: "features", label: "Features", status: "built", metadata: { sessionsCount: 15, commitsCount: 6 } },
{ id: "testimonials", label: "Testimonials", status: "in_progress", metadata: { sessionsCount: 3, commitsCount: 1 } },
{ id: "cta", label: "CTA", status: "built", metadata: { sessionsCount: 5, commitsCount: 2 } },
],
},
{
id: "blog",
label: "Blog",
status: "missing",
children: [
{ id: "post-list", label: "Post List", status: "missing" },
{ id: "post-page", label: "Post Page", status: "missing" },
],
},
{
id: "pricing",
label: "Pricing",
status: "built",
metadata: { sessionsCount: 7, commitsCount: 3 },
},
],
},
{
id: "onboarding",
label: "Onboarding",
children: [
{ id: "signup", label: "Signup", status: "built", metadata: { sessionsCount: 9, commitsCount: 4 } },
{ id: "magic-link", label: "Magic Link Confirmation", status: "in_progress", metadata: { sessionsCount: 2, commitsCount: 1 } },
{ id: "welcome", label: "Welcome Tour", status: "missing" },
],
},
]);
useEffect(() => {
loadDesignItems();
}, [projectId]);
const loadDesignItems = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
if (response.ok) {
const data = await response.json();
// Filter for design/user-facing items only
const designItems = data.workItems.filter((item: WorkItem) =>
isTouchpoint(item)
);
setWorkItems(designItems);
}
} catch (error) {
console.error("Error loading design items:", error);
toast.error("Failed to load design items");
} finally {
setLoading(false);
}
};
const isTouchpoint = (item: WorkItem): boolean => {
const path = item.path.toLowerCase();
const title = item.title.toLowerCase();
// Exclude APIs and backend systems
if (path.startsWith('/api/')) return false;
if (title.includes(' api') || title.includes('api ')) return false;
// Exclude pure auth infrastructure (OAuth endpoints)
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false;
// Include everything else - screens, pages, flows, etc.
return true;
};
const toggleState = async (itemId: string, newState: "draft" | "final") => {
try {
// TODO: Implement API call to update state
setWorkItems(items =>
items.map(item =>
item.id === itemId ? { ...item, state: newState } : item
)
);
toast.success(`Marked as ${newState}`);
} catch (error) {
toast.error("Failed to update state");
}
};
const openInV0 = (item: WorkItem) => {
// TODO: Integrate with v0 API
toast.info("Opening in v0 designer...");
};
const getStatusIcon = (status: string) => {
if (status === "built") return <CheckCircle2 className="h-4 w-4 text-green-600" />;
if (status === "in_progress") return <Clock className="h-4 w-4 text-blue-600" />;
return <Circle className="h-4 w-4 text-gray-400" />;
};
const getStatusLabel = (status: string) => {
if (status === "built") return "Done";
if (status === "in_progress") return "Started";
return "To-do";
};
const filteredItems = workItems.filter(item => {
if (filterState === "all") return true;
return item.state === filterState;
});
return (
<div className="relative h-full w-full bg-background overflow-hidden flex">
{/* Left Sidebar */}
<CollapsibleSidebar>
<div className="space-y-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Design Assets
</h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{workItems.length}
</Badge>
</div>
{/* Tree View */}
<TreeView
data={treeData}
selectedId={selectedTreeNodeId}
onSelect={(node) => {
setSelectedTreeNodeId(node.id);
toast.info(`Selected: ${node.label}`);
}}
/>
{/* Quick Stats at Bottom */}
<div className="pt-3 mt-3 border-t space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Built</span>
<span className="font-medium text-green-600">
{treeData.reduce((acc, node) => acc + countNodesByStatus(node, "built"), 0)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">In Progress</span>
<span className="font-medium text-blue-600">
{treeData.reduce((acc, node) => acc + countNodesByStatus(node, "in_progress"), 0)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">To Build</span>
<span className="font-medium text-gray-600">
{treeData.reduce((acc, node) => acc + countNodesByStatus(node, "missing"), 0)}
</span>
</div>
</div>
</div>
</CollapsibleSidebar>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="border-b bg-background p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Palette className="h-6 w-6" />
<div>
<h1 className="text-xl font-bold">Design</h1>
<p className="text-sm text-muted-foreground">
User-facing screens, features, and design assets
</p>
</div>
</div>
<Button className="gap-2">
<Plus className="h-4 w-4" />
New Design
</Button>
</div>
{/* Filters */}
<div className="flex items-center gap-2 mt-4">
<Button
variant={filterState === "all" ? "secondary" : "ghost"}
size="sm"
onClick={() => setFilterState("all")}
>
All
</Button>
<Button
variant={filterState === "draft" ? "secondary" : "ghost"}
size="sm"
onClick={() => setFilterState("draft")}
>
Draft
</Button>
<Button
variant={filterState === "final" ? "secondary" : "ghost"}
size="sm"
onClick={() => setFilterState("final")}
>
Final
</Button>
<Separator orientation="vertical" className="h-6 mx-2" />
<Badge variant="secondary">
{filteredItems.length} items
</Badge>
</div>
</div>
{/* Work Items List */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Palette className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No design items yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Design items are user-facing elements like screens and features
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create First Design
</Button>
</div>
) : (
<div className="space-y-3">
{filteredItems.map((item) => (
<Card key={item.id} className="p-4 hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{getStatusIcon(item.status)}
<div className="flex-1 space-y-2">
{/* Title and Status */}
<div className="flex items-center gap-2">
<h3 className="font-semibold">{item.title}</h3>
<Badge variant="outline" className="text-xs">
{getStatusLabel(item.status)}
</Badge>
{item.state && (
<Badge
variant={item.state === "final" ? "default" : "secondary"}
className="text-xs"
>
{item.state === "final" ? "Final" : "Draft"}
</Badge>
)}
</div>
{/* Path */}
<p className="text-sm text-muted-foreground font-mono">
{item.path}
</p>
{/* Stats */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{item.sessionsCount} sessions</span>
<span></span>
<span>{item.commitsCount} commits</span>
{item.estimatedCost && (
<>
<span></span>
<span>${item.estimatedCost.toFixed(2)}</span>
</>
)}
{item.versionCount && (
<>
<span></span>
<span>{item.versionCount} versions</span>
</>
)}
{item.messageCount && (
<>
<span></span>
<span>{item.messageCount} messages</span>
</>
)}
</div>
{/* Requirements Preview */}
{item.requirements.length > 0 && (
<div className="mt-2">
<p className="text-xs text-muted-foreground mb-1">
{item.requirements.filter(r => r.status === "built").length} of{" "}
{item.requirements.length} requirements complete
</p>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => openInV0(item)}
>
<Sparkles className="h-4 w-4" />
Design
</Button>
<Button
variant="outline"
size="sm"
onClick={() => toast.info("Version history coming soon")}
>
<History className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => toast.info("Messages coming soon")}
>
<MessageSquare className="h-4 w-4" />
</Button>
{/* State Toggle */}
{item.state !== "final" && (
<Button
variant="default"
size="sm"
onClick={() => toggleState(item.id, "final")}
>
Mark Final
</Button>
)}
{item.state === "final" && (
<Button
variant="outline"
size="sm"
onClick={() => toggleState(item.id, "draft")}
>
Back to Draft
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
</div>
{/* End Main Content */}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,12 @@
import { AppShell } from "@/components/layout/app-shell";
import { query } from "@/lib/db-postgres";
/**
* Passthrough layout for the project route.
*
* Two sibling route groups provide their own scaffolds:
* - (home)/ — VIBNSidebar scaffold for the project home page.
* - (workspace)/ — ProjectShell (top tab nav) for overview/build/run/etc.
*/
import { ReactNode } from "react";
async function getProjectName(projectId: string): Promise<string> {
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length > 0) {
const data = rows[0].data;
return data?.productName || data?.name || "Project";
}
} catch (error) {
console.error("Error fetching project name:", error);
}
return "Project";
}
export default async function ProjectLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
const projectName = await getProjectName(projectId);
return (
<AppShell workspace={workspace} projectId={projectId} projectName={projectName}>
{children}
</AppShell>
);
export default function ProjectRootLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

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

View File

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

View File

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

View File

@@ -1,506 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
GitBranch,
GitCommit,
GitPullRequest,
CircleDot,
ExternalLink,
Terminal,
Rocket,
Database,
Loader2,
CheckCircle2,
XCircle,
Clock,
AlertCircle,
Code2,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
interface ContextSnapshot {
lastCommit?: {
sha: string;
message: string;
author?: string;
timestamp?: string;
url?: string;
};
currentBranch?: string;
recentCommits?: { sha: string; message: string; author?: string; timestamp?: string }[];
openPRs?: { number: number; title: string; url: string; from: string; into: string }[];
openIssues?: { number: number; title: string; url: string; labels?: string[] }[];
lastDeployment?: {
status: string;
url?: string;
timestamp?: string;
deploymentUuid?: string;
};
updatedAt?: string;
}
interface Project {
id: string;
name: string;
productName: string;
productVision?: string;
slug?: string;
workspace?: string;
status?: string;
currentPhase?: string;
projectType?: string;
// Gitea
giteaRepo?: string;
giteaRepoUrl?: string;
giteaCloneUrl?: string;
giteaSshUrl?: string;
giteaWebhookId?: number;
giteaError?: string;
// Coolify
coolifyProjectUuid?: string;
coolifyAppUuid?: string;
coolifyDbUuid?: string;
deploymentUrl?: string;
// Theia
theiaWorkspaceUrl?: string;
// Context
contextSnapshot?: ContextSnapshot;
stats?: { sessions: number; costs: number };
createdAt?: string;
updatedAt?: string;
}
function timeAgo(ts?: string): string {
if (!ts) return "—";
const d = new Date(ts);
if (isNaN(d.getTime())) return "—";
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function DeployBadge({ status }: { status?: string }) {
if (!status) return <Badge variant="secondary">No deployments</Badge>;
const map: Record<string, { label: string; icon: React.ElementType; className: string }> = {
finished: { label: "Deployed", icon: CheckCircle2, className: "bg-green-500/10 text-green-600 border-green-500/20" },
in_progress: { label: "Deploying", icon: Loader2, className: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
queued: { label: "Queued", icon: Clock, className: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" },
failed: { label: "Failed", icon: XCircle, className: "bg-red-500/10 text-red-600 border-red-500/20" },
cancelled: { label: "Cancelled", icon: XCircle, className: "bg-gray-500/10 text-gray-500 border-gray-500/20" },
};
const cfg = map[status] ?? { label: status, icon: AlertCircle, className: "bg-gray-500/10 text-gray-500" };
const Icon = cfg.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${cfg.className}`}>
<Icon className="h-3 w-3" />
{cfg.label}
</span>
);
}
export default function ProjectOverviewPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const { status: authStatus } = useSession();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const fetchProject = async () => {
try {
const res = await fetch(`/api/projects/${projectId}`);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to load project");
}
const data = await res.json();
setProject(data.project);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
if (authStatus === "authenticated") fetchProject();
else if (authStatus === "unauthenticated") setLoading(false);
}, [authStatus, projectId]);
const handleRefresh = () => {
setRefreshing(true);
fetchProject();
};
const handleProvisionWorkspace = async () => {
setProvisioning(true);
try {
const res = await fetch(`/api/projects/${projectId}/workspace`, { method: 'POST' });
const data = await res.json();
if (res.ok && data.workspaceUrl) {
toast.success('Workspace provisioned — starting up…');
await fetchProject();
} else {
toast.error(data.error || 'Failed to provision workspace');
}
} catch {
toast.error('An error occurred');
} finally {
setProvisioning(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !project) {
return (
<div className="container mx-auto py-8 px-6 max-w-5xl">
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="py-8 text-center">
<p className="text-sm text-red-600">{error ?? "Project not found"}</p>
</CardContent>
</Card>
</div>
);
}
const snap = project.contextSnapshot;
const gitea_url = process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.vibnai.com";
return (
<div className="container mx-auto py-8 px-6 max-w-5xl space-y-6">
{/* ── Header ── */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">{project.productName}</h1>
{project.productVision && (
<p className="text-muted-foreground text-sm mt-1 max-w-xl">{project.productVision}</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant={project.status === "active" ? "default" : "secondary"}>
{project.status ?? "active"}
</Badge>
{project.currentPhase && (
<Badge variant="outline">{project.currentPhase}</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
{project.theiaWorkspaceUrl ? (
<Button size="sm" asChild>
<a href={project.theiaWorkspaceUrl} target="_blank" rel="noopener noreferrer">
<Terminal className="h-4 w-4 mr-1.5" />
Open IDE
</a>
</Button>
) : (
<Button size="sm" onClick={handleProvisionWorkspace} disabled={provisioning}>
{provisioning
? <><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Provisioning</>
: <><Terminal className="h-4 w-4 mr-1.5" />Provision IDE</>
}
</Button>
)}
</div>
</div>
{/* ── Quick Stats ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Sessions", value: project.stats?.sessions ?? 0 },
{ label: "AI Cost", value: `$${(project.stats?.costs ?? 0).toFixed(2)}` },
{ label: "Open PRs", value: snap?.openPRs?.length ?? 0 },
{ label: "Open Issues", value: snap?.openIssues?.length ?? 0 },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-5 pb-4">
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* ── Code / Gitea ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Code2 className="h-4 w-4" />
Code Repository
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{project.giteaRepo ? (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-mono text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
{snap?.currentBranch ?? "main"}
</div>
<a
href={project.giteaRepoUrl ?? `${gitea_url}/${project.giteaRepo}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary flex items-center gap-1 hover:underline"
>
{project.giteaRepo}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{snap?.lastCommit ? (
<div className="rounded-md border bg-muted/30 p-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<GitCommit className="h-3.5 w-3.5" />
<span className="font-mono">{snap.lastCommit.sha.slice(0, 8)}</span>
<span>·</span>
<span>{timeAgo(snap.lastCommit.timestamp)}</span>
{snap.lastCommit.author && <span>· {snap.lastCommit.author}</span>}
</div>
<p className="text-sm font-medium line-clamp-1">{snap.lastCommit.message}</p>
</div>
) : (
<p className="text-xs text-muted-foreground">No commits yet push to get started</p>
)}
<div className="text-xs text-muted-foreground space-y-1 pt-1 border-t">
<p className="font-medium text-foreground">Clone</p>
<p className="font-mono break-all">{project.giteaCloneUrl}</p>
{project.giteaSshUrl && (
<p className="font-mono break-all">{project.giteaSshUrl}</p>
)}
</div>
</>
) : (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">
{project.giteaError
? `Repo provisioning failed: ${project.giteaError}`
: "No repository linked"}
</p>
</div>
)}
</CardContent>
</Card>
{/* ── Deployment ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Rocket className="h-4 w-4" />
Deployment
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{snap?.lastDeployment ? (
<>
<div className="flex items-center justify-between">
<DeployBadge status={snap.lastDeployment.status} />
<span className="text-xs text-muted-foreground">{timeAgo(snap.lastDeployment.timestamp)}</span>
</div>
{snap.lastDeployment.url && (
<a
href={snap.lastDeployment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
{snap.lastDeployment.url}
</a>
)}
</>
) : (
<div className="text-center py-4 space-y-3">
<p className="text-sm text-muted-foreground">No deployments yet</p>
<Button size="sm" variant="outline" asChild>
<Link href={`/${workspace}/project/${projectId}/deployment`}>
Set up deployment
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Open PRs ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
Pull Requests
{(snap?.openPRs?.length ?? 0) > 0 && (
<Badge variant="secondary" className="ml-auto">{snap!.openPRs!.length} open</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{snap?.openPRs?.length ? (
<ul className="space-y-2">
{snap.openPRs.map(pr => (
<li key={pr.number}>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
>
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{pr.number}</span>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{pr.title}</p>
<p className="text-xs text-muted-foreground">{pr.from} {pr.into}</p>
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No open pull requests</p>
)}
</CardContent>
</Card>
{/* ── Open Issues ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CircleDot className="h-4 w-4" />
Issues
{(snap?.openIssues?.length ?? 0) > 0 && (
<Badge variant="secondary" className="ml-auto">{snap!.openIssues!.length} open</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{snap?.openIssues?.length ? (
<ul className="space-y-2">
{snap.openIssues.map(issue => (
<li key={issue.number}>
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
>
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{issue.number}</span>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{issue.title}</p>
{issue.labels?.length ? (
<div className="flex gap-1 flex-wrap mt-0.5">
{issue.labels.map(l => (
<span key={l} className="text-[10px] px-1.5 py-0.5 bg-muted rounded-full">{l}</span>
))}
</div>
) : null}
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No open issues</p>
)}
</CardContent>
</Card>
</div>
{/* ── Recent Commits ── */}
{snap?.recentCommits && snap.recentCommits.length > 1 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<GitCommit className="h-4 w-4" />
Recent Commits
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{snap.recentCommits.map((c, i) => (
<li key={i} className="flex items-center gap-3 text-sm py-1.5 border-b last:border-0">
<span className="font-mono text-xs text-muted-foreground w-16 shrink-0">{c.sha.slice(0, 8)}</span>
<span className="flex-1 line-clamp-1">{c.message}</span>
<span className="text-xs text-muted-foreground shrink-0">{c.author ?? ""}</span>
<span className="text-xs text-muted-foreground shrink-0 w-16 text-right">{timeAgo(c.timestamp)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* ── Resources ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Database className="h-4 w-4" />
Resources
</CardTitle>
<CardDescription className="text-xs">Databases and services linked to this project</CardDescription>
</CardHeader>
<CardContent>
{project.coolifyDbUuid ? (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Database provisioned</span>
<Badge variant="outline" className="text-xs ml-auto">{project.coolifyDbUuid}</Badge>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">No databases provisioned yet</p>
<Button size="sm" variant="outline">
<Database className="h-3.5 w-3.5 mr-1.5" />
Add Database
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Context snapshot freshness ── */}
{snap?.updatedAt && (
<p className="text-xs text-muted-foreground text-right">
Context updated {timeAgo(snap.updatedAt)} via webhooks
</p>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,357 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Save, FolderOpen, AlertCircle } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { db, auth } from "@/lib/firebase/config";
import { doc, getDoc, updateDoc, serverTimestamp } from "firebase/firestore";
import { toast } from "sonner";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
interface Project {
id: string;
name: string;
productName: string;
productVision?: string;
workspacePath?: string;
workspaceName?: string;
githubRepo?: string;
chatgptUrl?: string;
projectType: string;
status: string;
}
export default function ProjectSettingsPage() {
const params = useParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [orphanedSessionsCount, setOrphanedSessionsCount] = useState(0);
// Form state
const [productName, setProductName] = useState("");
const [productVision, setProductVision] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
useEffect(() => {
const fetchProject = async () => {
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
router.push('/auth');
return;
}
const projectDoc = await getDoc(doc(db, 'projects', projectId));
if (!projectDoc.exists()) {
toast.error('Project not found');
router.push(`/${workspace}/projects`);
return;
}
const projectData = projectDoc.data() as Project;
setProject({ ...projectData, id: projectDoc.id });
// Set form values
setProductName(projectData.productName);
setProductVision(projectData.productVision || "");
setWorkspacePath(projectData.workspacePath || "");
// Check for orphaned sessions from old workspace path
if (projectData.workspacePath) {
// This would require checking sessions - we'll implement this in the API
// For now, just show the UI
}
} catch (err: any) {
console.error('Error fetching project:', err);
toast.error('Failed to load project');
} finally {
setLoading(false);
}
};
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
fetchProject();
} else {
router.push('/auth');
}
});
return () => unsubscribe();
}, [projectId, workspace, router]);
const handleSave = async () => {
setSaving(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in');
return;
}
// Get the directory name from the path
const workspaceName = workspacePath ? workspacePath.split('/').pop() || '' : '';
await updateDoc(doc(db, 'projects', projectId), {
productName,
productVision,
workspacePath,
workspaceName,
updatedAt: serverTimestamp(),
});
toast.success('Project settings saved!');
// Refresh project data
const projectDoc = await getDoc(doc(db, 'projects', projectId));
if (projectDoc.exists()) {
setProject({ ...projectDoc.data() as Project, id: projectDoc.id });
}
} catch (error) {
console.error('Error saving project:', error);
toast.error('Failed to save settings');
} finally {
setSaving(false);
}
};
const handleSelectDirectory = async () => {
try {
// Check if File System Access API is supported
if ('showDirectoryPicker' in window) {
const dirHandle = await (window as any).showDirectoryPicker({
mode: 'read',
});
if (dirHandle?.name) {
// Provide a path hint (browsers don't expose full paths for security)
const pathHint = `~/projects/${dirHandle.name}`;
setWorkspacePath(pathHint);
toast.info('Update the path to match your actual folder location', {
description: 'You can get the full path from Finder/Explorer or your terminal'
});
}
} else {
toast.error('Directory picker not supported in this browser', {
description: 'Please enter the path manually or use Chrome/Edge'
});
}
} catch (error: any) {
// User cancelled or denied permission
if (error.name !== 'AbortError') {
console.error('Error selecting directory:', error);
toast.error('Failed to select directory');
}
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!project) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Project not found</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-auto">
{/* Header */}
<div className="border-b px-6 py-4">
<h1 className="text-2xl font-bold">Project Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your project configuration and workspace settings
</p>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-4xl space-y-6">
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General Information</CardTitle>
<CardDescription>
Basic details about your project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="productName">Product Name</Label>
<Input
id="productName"
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="My Awesome Product"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productVision">Product Vision</Label>
<Textarea
id="productVision"
value={productVision}
onChange={(e) => setProductVision(e.target.value)}
placeholder="Describe what you're building and who it's for..."
rows={4}
/>
</div>
</CardContent>
</Card>
{/* Workspace Settings */}
<Card>
<CardHeader>
<CardTitle>Workspace Path</CardTitle>
<CardDescription>
The local directory where you're coding this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Why update this?</AlertTitle>
<AlertDescription>
If you renamed your project folder or moved it to a different location,
update the path here so Vibn can correctly track your coding sessions.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="workspacePath">Local Workspace Path</Label>
<div className="flex gap-2">
<Input
id="workspacePath"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder="/Users/you/projects/my-project"
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSelectDirectory}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
💡 <strong>Tip:</strong> Right-click your project folder → Get Info (Mac) or Properties (Windows) to copy the full path
</p>
</div>
{project.workspacePath && workspacePath !== project.workspacePath && (
<Alert className="border-orange-500/50 bg-orange-500/10">
<AlertCircle className="h-4 w-4 text-orange-600" />
<AlertTitle>Path Changed</AlertTitle>
<AlertDescription>
You're changing the workspace path from <code className="text-xs bg-muted px-1 py-0.5 rounded">{project.workspacePath}</code> to <code className="text-xs bg-muted px-1 py-0.5 rounded">{workspacePath}</code>.
<br /><br />
After saving, Vibn will track sessions from the new path. Any existing sessions from the old path will remain associated with this project.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Connected Services */}
<Card>
<CardHeader>
<CardTitle>Connected Services</CardTitle>
<CardDescription>
External integrations for this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">GitHub Repository</p>
<p className="text-sm text-muted-foreground">
{project.githubRepo || 'Not connected'}
</p>
</div>
{project.githubRepo && (
<a
href={`https://github.com/${project.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View on GitHub
</a>
)}
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">ChatGPT Project</p>
<p className="text-sm text-muted-foreground">
{project.chatgptUrl ? 'Connected' : 'Not connected'}
</p>
</div>
{project.chatgptUrl && (
<a
href={project.chatgptUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Open ChatGPT
</a>
)}
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end gap-3 pt-4">
<Button
variant="outline"
onClick={() => router.push(`/${workspace}/project/${projectId}/overview`)}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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