diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b97de8 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Database — Supabase PostgreSQL +# Transaction pooler URL from Supabase dashboard (Settings → Database → Connection string, port 6543) +DATABASE_URL="postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true" +# Direct connection URL (port 5432) — required by Prisma for migrations +DIRECT_URL="postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres" + +# GitHub OAuth (NextAuth "Sign in with GitHub") +# Callback URL in GitHub OAuth App settings MUST be: +# http://localhost:3000/api/auth/callback/github +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# NextAuth — generate a secret: openssl rand -base64 32 +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +# AI +OPENAI_API_KEY= +ANTHROPIC_API_KEY= + +# Queue +REDIS_URL=redis://localhost:6379 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/.gitignore b/.gitignore index 348b247..b4f23cc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ yarn-error.log* # TypeScript *.tsbuildinfo next-env.d.ts + +# Local Prisma SQLite (created by migrate) +prisma/*.db +prisma/*.db-journal diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..79ab4b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. + +## Cursor Cloud specific instructions + +### Product + +Devlog is a single Next.js 14 app (not a monorepo). One process (`npm run dev` or `npm start`) serves the UI and all API routes. SQLite via Prisma stores users/sessions; GitHub OAuth and Anthropic APIs are external. + +### Update script vs manual setup + +The VM **update script** only runs `npm install` (which triggers `postinstall` → `prisma generate`). It does **not** start services, run migrations, or create `.env.local`. + +On a **fresh clone**, after `npm install`: + +1. `cp .env.example .env.local` and fill secrets (see README). +2. Apply DB migrations (see SQLite note below). +3. Start the dev server (see Running). + +### SQLite `DATABASE_URL` gotcha + +Prisma CLI resolves SQLite paths **relative to `prisma/schema.prisma`**, while the Next.js app resolves them **relative to the repo root**. The example `file:./prisma/dev.db` can create `prisma/prisma/dev.db` when using the CLI but `prisma/dev.db` at runtime. + +**Recommended for Cloud Agents:** use an absolute path in `.env.local`, e.g. `DATABASE_URL="file:/workspace/prisma/dev.db"`, then run migrations with that file sourced: + +```bash +set -a && source .env.local && set +a && npx prisma migrate deploy +``` + +### Running the dev server + +Use a dedicated tmux session (do not rely on one-shot background shells): + +```bash +SESSION_NAME="devlog-dev" +tmux -f /exec-daemon/tmux.portal.conf has-session -t "=$SESSION_NAME" 2>/dev/null \ + || tmux -f /exec-daemon/tmux.portal.conf new-session -d -s "$SESSION_NAME" -c /workspace -- "${SHELL:-bash}" -l +tmux -f /exec-daemon/tmux.portal.conf send-keys -t "$SESSION_NAME:0.0" 'cd /workspace && npm run dev' C-m +``` + +App URL: http://localhost:3000 + +### Lint / typecheck / build + +See `package.json` scripts: `npm run lint`, `npm run type-check`, `npm run build`. There is no `test` script or `tests/` directory in the repo today. + +### What works without secrets + +| Flow | Requirements | +|------|----------------| +| Landing, marketing UI | Dev server only | +| Login UI | Dev server + placeholder or real `GITHUB_*` + `NEXTAUTH_*` | +| GitHub sign-in, timeline, generate | Real GitHub OAuth app + user session; generate also needs `ANTHROPIC_API_KEY` | + +Protected app routes (e.g. `/timeline`, `/dashboard`) redirect to `/login` when unauthenticated. + +### Optional / unused today + +`REDIS_URL`, `OPENAI_API_KEY`, and Postgres are documented in `.env.example` but not used by current `src/` code. No Docker Compose or separate worker process. diff --git a/README.md b/README.md index c543227..99fe577 100644 --- a/README.md +++ b/README.md @@ -184,15 +184,19 @@ Help developers grow audiences, acquire early users, build communities, and laun ## Tech Stack +Devlog is built on a modern, industry-standard web stack that’s common across SaaS dashboards and developer tools. + | Layer | Technology | Purpose | |-------|-----------|---------| -| **Frontend** | Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS, Geist font | Dashboard, timeline, content editor, analytics, calendar UI | -| **Styling** | Tailwind CSS, `clsx`, `tailwind-merge`, `class-variance-authority`, `lucide-react` | Dark-mode design system, variant-based components, icon library | -| **Backend** | Next.js Route Handlers, TypeScript | GitHub event processing, AI generation, API routing, platform integrations | -| **AI** | OpenAI API, Anthropic API, local model support *(planned)* | Commit summarization, post generation, tone adaptation, thread formatting | -| **Database** | PostgreSQL + Prisma | Users, repositories, generated posts, platform tokens, analytics | -| **Queue** | Redis, BullMQ | AI job processing, scheduled posts, event pipelines | -| **Infra** | Vercel, Railway / Fly.io, Supabase, Cloudflare | Frontend hosting, backend hosting, auth + database, edge security | +| **Frontend** | Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS, Geist font, Framer Motion | Modern, responsive dashboard UI, timeline, content editor, analytics, and calendar interactions | +| **Styling** | Tailwind CSS, `clsx`, `tailwind-merge`, `class-variance-authority`, `lucide-react` | Dark-mode design system, variant-based components, utility composition, and icons | +| **Backend** | Next.js Route Handlers, TypeScript | GitHub webhook handling, event processing, AI orchestration, API routing, and platform integrations | +| **AI** | OpenAI API, Anthropic API, local model support *(planned)* | Commit summarization, outcome-focused narrative, multi-platform post generation, and tone adaptation | +| **Database** | PostgreSQL + Prisma | Users, repositories, events, generated posts, privacy settings, and analytics | +| **Queue** | Redis, BullMQ *(or equivalent)* | Background AI job processing, scheduled posts, and event pipelines | +| **Infra** | Vercel, Railway / Fly.io, Supabase/Neon, Cloudflare | Frontend hosting, backend services, managed Postgres, auth, and edge security | + +Python and FastAPI are also strong candidates for AI-heavy backend components in the future, but the initial product surface is optimized around the React + TypeScript + Next.js ecosystem that most modern dev tools use. --- @@ -201,8 +205,8 @@ Help developers grow audiences, acquire early users, build communities, and laun ### Prerequisites - Node.js 18+ -- PostgreSQL -- Redis +- A **GitHub OAuth App** (for “Sign in with GitHub”) +- **SQLite** is used by default for local development (no Postgres required to start). Redis/Postgres remain optional for future features. ### Setup @@ -215,20 +219,27 @@ cd devlog npm install # Configure environment variables -cp .env.example .env +cp .env.example .env.local ``` -Add your keys to `.env`: +Add your keys to `.env.local` at minimum: ```env -OPENAI_API_KEY= +DATABASE_URL="file:./prisma/dev.db" GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -DATABASE_URL= -REDIS_URL= +NEXTAUTH_SECRET= # e.g. openssl rand -base64 32 +NEXTAUTH_URL=http://localhost:3000 ``` +In your **GitHub OAuth App**, set **Authorization callback URL** to: + +`http://localhost:3000/api/auth/callback/github` + ```bash +# Create / update the local database +npx prisma migrate dev + # Start development server npm run dev ``` @@ -284,9 +295,47 @@ This keeps `main` clean for deployments while preserving a clear history of feat --- +## Project Structure + +Devlog currently uses a **single-app structure** optimized for fast iteration, with a clean path to a future monorepo split if the backend grows significantly. + +```text +docs/ architecture, API contracts, decision records, agent coordination +src/app/ Next.js routes and route handlers +src/components/ reusable UI components +src/features/ feature-level domains (github, timeline, privacy, post generation, etc.) +src/lib/ shared helpers and infrastructure clients +src/server/ server-only services, repositories, jobs, and schemas +src/styles/ global styles +src/types/ shared application types +prisma/ database schema and migrations +scripts/ local automation scripts +tests/ unit and integration tests +.github/workflows/ CI and automation workflows +``` + +### Agent coordination rule + +Every `feature/*` branch must update `docs/agent-worklog.md` in its first meaningful commit so each implementation chat can quickly understand: + +- what branch is active, +- what area is being changed, +- what files are expected to be touched, +- and whether the work depends on any API or data model changes. + +Supporting docs: + +- `docs/CHANGELOG.md` — dated milestones (start here for “what changed when”) +- `docs/architecture.md` +- `docs/api-contracts.md` +- `docs/agent-worklog.md` +- `docs/decisions/` + +--- + ## Current Build Status -> Last updated: Mar 19, 2026 · `v0.1.0` · branch `feature/dev-timeline-ui` +> Last updated: Mar 19, 2026 · `v0.1.0` · merged into `main` | Area | Status | Notes | |------|--------|-------| @@ -295,7 +344,7 @@ This keeps `main` clean for deployments while preserving a clear history of feat | Dashboard | ✅ Prototype | Static placeholder data | | Dev Timeline UI | ✅ Done | Fully componentized, fake data, live search + type filter | | Settings page | ✅ Prototype | UI shell only | -| GitHub integration | 🔲 Planned | OAuth, webhook, event normalization | +| GitHub integration | 🟡 In Progress | OAuth connect, repo picker, commit fetch (no webhooks yet) | | AI post generator | 🔲 Planned | LLM prompt pipeline | | Content editor | 🔲 Planned | Rich text, tone adjustment | | Multi-platform publishing | 🔲 Planned | X, LinkedIn, Reddit, Dev.to | @@ -330,6 +379,36 @@ Community features, developer leaderboard, automatic devlogs, visual content gen --- +## Release Milestones + +Devlog will use simple semantic versioning to mark meaningful product stages. + +### v0.1.0 – Internal MVP + +- GitHub OAuth + repo selection for a single user. +- Basic event capture from commits and/or pull requests. +- Simple internal-only dev timeline UI with fake or minimal AI summaries. +- No external users yet, used only by the maker to test core workflows. + +### v0.2.0 – Early Access (Friends & Testers) + +- Reliable GitHub event ingestion for selected repos. +- AI-generated summaries for meaningful events (commit/PR → outcome-focused text). +- Editable dev timeline entries and a first version of privacy levels (e.g., high vs medium). +- Ability to generate draft posts for at least reddit, maybe X and LinkedIn. +- Onboarding that a small group of external testers can complete without hand-holding. + +### v1.0.0 – Public Launch + +- Stable GitHub integration with clear error handling and status. +- Polished dev timeline and content calendar suitable for daily use. +- Mature privacy controls, documented and easy to understand. +- Multi-platform post generation with tone presets (X, LinkedIn, Reddit at minimum). +- Basic analytics (consistency and top-performing posts) and a simple paid plan. +- Public landing page, documentation, and GitHub release tagged `v1.0.0`. + +--- + ## Pricing (Planned) Devlog will ship with a simple, pragmatic pricing model designed to balance adoption and sustainability. @@ -365,6 +444,7 @@ Devlog will ship with a simple, pragmatic pricing model designed to balance adop | **Automatic Devlogs** | AI-generated weekly progress reports | | **Visual Content Generation** | Feature screenshots, product mockups, diagrams | | **Audience Discovery** | Identify potential users by interest, engagement, and topic relevance | +| **Devlog → Docs Integration** | Export timeline entries into long-form documentation (e.g. wiki-style pages) for design notes, changelogs, and internal knowledge bases | --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..cd6dfc6 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,83 @@ +# Documentation & product changelog + +This file lists **major milestones** so anyone opening `docs/` can see **what changed and when**. +For day-to-day task detail, see **`agent-worklog.md`**. For HTTP shapes, see **`api-contracts.md`**. + +Entries are **newest first**. Dates use **ISO-style `YYYY-MM-DD`** when known; otherwise **`YYYY-MM`**. + +--- + +## 2026-05-04 + +| Area | What changed | +|------|----------------| +| Post generator | **Phase A — enrichment layer:** `enrichEvent.ts` (Anthropic Haiku pre-pass), `sanitizeEvent.ts` (privacy levels). | +| Post generator | **Phase B.1 — LinkedIn prompt template:** `templates/linkedinTemplate.ts` (`buildLinkedInPrompt`) — story-arc structure, tone-aware, ≤2 hashtags, hype-opener block. | +| Post generator | **Phase B.2 — X prompt template:** `templates/xTemplate.ts` (`buildXPrompt`) — hook-first (first 8 words), ≤260 char hard cap, no `I`/`We`/`Just` openers, banned generic-spam hashtags, in-prompt char self-check. Replaces earlier same-day stub. | +| Post generator | **Phase B.3 — Reddit prompt template:** `templates/redditTemplate.ts` (`buildRedditPrompt`) — title + body, devlog voice, banned-vocab list, tone-aware, no hashtags, uses `difficulty` for calibration. *(Renumbered from B.2 → B.3 on the same day when the X template took B.2.)* | +| Post generator | **Phase C.1 — Core `generatePost`:** `generatePost.ts` (`Platform`, `GeneratedPost`, `generatePost`) — wires enricher + sanitizer + templates → Anthropic `claude-sonnet-4-6`, X retry-once-when-over-280 logic, throws `Failed to generate ${platform} post: ${message}` on any failure. | +| API| **Phase C.2 — `POST /api/posts/generate` rewritten:** route now drives the full pipeline (enrich → sanitize → anchor pick → parallel generation). **Response shape changed** from bare `GeneratedPost[]` to `{ posts: GeneratedPost[] }`, and each post now includes `characterCount`. Internal HTTP `fetch /api/timeline` (session cookie forwarded) replaces direct Prisma access. | +| App | **C.2-followup — Generator UI parse fixed:** `src/app/(app)/generate/page.tsx` now destructures `{ posts }` from the new `{ posts: GeneratedPost[] }` response envelope. One-line data-parsing change only; no UI, state, or other logic altered. | +| Docs | `api-contracts.md` — `POST /api/posts/generate` rewritten with new pipeline semantics, anchor-event rule, and updated response shape. Internal modules for enrich, sanitize, LinkedIn / X / Reddit templates, and `generatePost` documented. | +| Post generator | **C.3 — Retry wrapper + error resilience:** `withRetry.ts` (1 retry, 500 ms delay, returns `null` on total failure). Route now returns `207 { posts, failed }` on partial success and `500` only when all platforms fail. | +| UI | **D.1 — `EventSelector` component:** `src/components/postGenerator/EventSelector.tsx` — selectable timeline event cards; amber/violet/blue/emerald badges by type; summary truncated at 100 chars; "Select all / Deselect all" toggle; keyboard accessible. | + +| UI | **D.2 — `OptionsPanel` component:** `src/components/postGenerator/OptionsPanel.tsx` — platform toggles (colored dots), 4-option tone selector with descriptions, 3-option privacy selector (emerald/amber/red accent per risk level). Fully controlled, `aria-pressed` on all toggles. | + + +| UI | **D.3 — `ResultsPanel` component:** `src/components/postGenerator/ResultsPanel.tsx` — auto-resize textareas, X char counter (red >280, amber >90%), clipboard copy with 2s "Copied!" feedback, per-platform Regenerate button with spinner. | + +| UI | **D.4 — Generate page wired up:** `src/app/(app)/generate/page.tsx` rewritten — two-column desktop layout, all three D-components composed, generate + per-platform regenerate handlers, 207 partial-failure warning, scroll-to-results, loading skeleton. Sidebar nav unchanged (already had Generate). | +--- + +## 2026-05 (month; see `agent-worklog` for task IDs) + +| Area | What changed | +|------|----------------| +| GitHub & timeline | **Phase 1:** Prisma/env checks, NextAuth audit, `src/lib/github` helpers, GitHub API routes audit. | +| GitHub & timeline | **Phase 2:** `fetchRepoPullRequests`, `fetchRepoReleases`, `normalizeEvents.ts`. | +| API | **`GET /api/timeline`** — commits + PRs + releases → `TimelineEntry[]`. | +| App | **Timeline page** wired to `/api/timeline` (server fetch with session). | +| Post generator | **`POST /api/posts/generate`** — Anthropic, platform drafts. | +| App | **Generate page** + sidebar link — select events, platforms, tone, privacy. | + +- !!!! important, the next step is to improve the landing hero page and the timeline UI ( for the timeline UI i want it to be more interactive and heavier on the motion graphic side, like a galaxy kind of vibe where each star is a cahnge and they can form some kind of timeline with dates and user can click to check on each of them, and together they form something big and cool that user can feel a sense of accomplishment when seeing them) +- update pro plan idea(better post generations access to more niche platforms and analystics and also theres should be a limit for the number of post generated by free users like 20 posts? also how much should the pro plan be, 6.99? or 12.99? ) +- Question: should I integrate a function where users can one click posts the content through our website or just leave it as of right now where user can just copy and paste the content from our website and post it to social media platforms themselevs, and if i want to integrate this functionality, how much effort do I have to put in such as " I might have to connect the API of each social media platform to my website and some of them I believe costs money. +--- + +## 2026-03-19 + +| Area | What changed | +|------|----------------| +| App / UI | **`feature/dev-timeline-ui`** merged to `main` — timeline types, filters, demo data, dashboard/settings/timeline routes, app shell alignment. | + +--- + +## 2026-03-18 + +| Area | What changed | +|------|----------------| +| App / UI | **`feature/app-shell`** merged to `main` — sidebar, topbar, `(app)` layout (PR #3). | + +--- + +## 2026-03-16 + +| Area | What changed | +|------|----------------| +| Marketing | **`feature/landing-page`** merged to `main` — full marketing landing (PR #2). | + +--- + +## Earlier foundation + +| When | What | +|------|------| +| **2026-03** (approx.) | Initial Next.js app, Tailwind, Prisma, NextAuth (GitHub), ADR-0001 structure. | + +--- + +## How to update this file + +When you ship a **user-visible** or **contract-changing** milestone, add a **dated section** at the top (or append under the right month) with a short table. Link to PRs or commits when helpful. diff --git a/docs/agent-worklog.md b/docs/agent-worklog.md index 62f3408..a460b7b 100644 --- a/docs/agent-worklog.md +++ b/docs/agent-worklog.md @@ -1,31 +1,129 @@ # Agent Worklog -This file is the shared coordination log for feature branches and focused implementation chats. +Shared coordination log for all feature branches and focused implementation chats. +Dates are the actual session dates from conversation timestamps unless marked *(estimate)*. + +--- + +## Chronological Timeline + +| Date | What happened | +|------|---------------| +| **2026-03-16** | `feature/landing-page` merged → `main` (PR #2) | +| **2026-03-18** | `feature/app-shell` merged → `main` (PR #3) | +| **2026-03-19** | `feature/dev-timeline-ui` merged → `main` | +| **2026-03 → 04** *(estimate)* | First GitHub integration slice: NextAuth + Prisma + API routes. Deferred for hardening — see `docs/todo-github-integration.md` | +| **2026-05-01** | Phase 1 begins — Task 1.1: Prisma schema + env audit | +| **2026-05-02** | Tasks 1.2–4.2: NextAuth audit, GitHub helpers, normalizer, `/api/timeline`, timeline page, post generator API + UI | +| **2026-05-02** | GitHub OAuth credentials set, `NEXTAUTH_URL` corrected to port 3001 | +| **2026-05-03** | Auth end-to-end verification — sign-in confirmed working, `access_token` stored in DB | +| **2026-05-04** | Phase A — Post generator enrichment: event enricher (`A.1`) + privacy sanitizer (`A.2`) | +| **2026-05-04** | Phase B — Post generator templates: LinkedIn prompt (`B.1`), X prompt (`B.2`), Reddit prompt (`B.3`) | +| **2026-05-04** | Phase C — Generation pipeline: core `generatePost` (`C.1`), pipeline-driven `POST /api/posts/generate` route (`C.2`), retry wrapper + partial-success (`C.3`) | +| **2026-05-04** | Phase D — UI: event selector (`D.1`), options panel (`D.2`), results panel (`D.3`), generate page wired up (`D.4`) | + +--- ## Rule Every `feature/*` branch must update this file in its first meaningful commit so future chats and agents can see: +- what branch is active +- what area is being changed +- which files or folders are expected to be touched +- any API or integration assumptions + +--- + +## Phase A — Post Generator: Enrichment Layer +**2026-05-04** + +| Task | Scope | Files | Status | Notes | +|------|-------|-------|--------|-------| +| **A.1** — Event enricher | Pre-pass that expands a `TimelineEntry` into richer content (whatChanged, whyItMatters, technicalDetail, outcome, difficulty) via Anthropic | `src/lib/postGenerator/enrichEvent.ts` *(new)* | ✅ done | Uses `claude-haiku-4-5`. Exports `EnrichedEvent` + `enrichEvent(entry)`. Falls back to entry fields on parse/network failure. `ANTHROPIC_API_KEY` guard. | +| **A.2** — Privacy sanitizer | Pure sync function — strips/replaces technical detail before enriched events reach any generation prompt | `src/lib/postGenerator/sanitizeEvent.ts` *(new)* | ✅ done | `sanitizeEvent(event, privacyLevel)`. `"high"`: blanks `technicalDetail`, redacts file paths/function calls/tokens via regex. `"medium"`: replaces `technicalDetail`, strips code snippets/stack traces. `"low"`: passthrough. | + +--- + +## Phase B — Post Generator: Platform Prompt Templates +**2026-05-04** + +| Task | Scope | Files | Status | Notes | +|------|-------|-------|--------|-------| +| **B.1** — LinkedIn prompt template | Pure prompt builder for LinkedIn drafts (story arc: hook → what built → why it matters → takeaway → soft close) | `src/lib/postGenerator/templates/linkedinTemplate.ts` *(new)* | ✅ done | Exports `buildLinkedInPrompt(event, tone)`. Length 150–300 words, ≤2 hashtags last line, blocks hype openers, tone-specific guidance for casual/professional/feedback-seeking/educational with neutral fallback. Empty `technicalDetail` triggers "do not invent specifics" guard for high-privacy posts. | +| **B.2** — X prompt template | Pure prompt builder for X/Twitter drafts (hook-first; first 8 words decide if reader stops) | `src/lib/postGenerator/templates/xTemplate.ts` *(new)* | ✅ done | Exports `buildXPrompt(event, tone)`. Hard cap **260 chars** (20-char buffer below 280). Hook MUST NOT start with `"I"`, `"We"`, or `"Just"` — lead with outcome or problem. 0–2 specific hashtags (blocks generic spam: `#coding`, `#developer`, `#tech`, `#programming`). Single post, never a thread. Self-check loop in prompt: count chars and rewrite if over 260. Injects only `whatChanged`, `outcome`, `difficulty` (no `whyItMatters` / `technicalDetail` — keeps post tight and avoids leaks). `difficulty` calibrates tone (significant = worth noting, trivial = understated). Tone variants: casual = punchy fragments OK, professional = complete sentences, feedback-seeking = short genuine question, educational = lead with the insight. **Replaces the previous B.3* stub.** | +| **B.3** — Reddit prompt template | Pure prompt builder for Reddit drafts (title + body, devlog voice — honest, self-aware, downvote-resistant) | `src/lib/postGenerator/templates/redditTemplate.ts` *(new)* | ✅ done | Exports `buildRedditPrompt(event, tone)`. Title ≤12 words on line 1, blank line, then body (tried → happened → learned → optional question). No hashtags. Banned vocab list (`excited`, `thrilled`, `game-changer`, `journey`, `passionate`, `leverage`, etc.). Tone variants: casual = informal/humor, professional = measured, feedback-seeking = ends with specific technical question, educational = lesson-framed. Always leans honest regardless of tone. Injects all 5 enriched fields including `difficulty` for calibration. *(Renumbered from B.2 → B.3 on 2026-05-04.)* | + +--- + +## Phase C — Post Generator: Generation Pipeline +**2026-05-04** + +| Task | Scope | Files | Status | Notes | +|------|-------|-------|--------|-------| +| **C.1** — Core `generatePost` | Single function that ties enriched event + platform + tone → one generated post via Anthropic | `src/lib/postGenerator/generatePost.ts` *(new)* | ✅ done | Exports `Platform`, `GeneratedPost` types and `generatePost(event, platform, tone)`. Selects prompt builder via exhaustive `switch`. Calls `claude-sonnet-4-6` at `https://api.anthropic.com/v1/messages` with `max_tokens: 1000`. Cleans output (strips Markdown fences and surrounding quotes). For platform `"x"`: if content > 280 chars, retries ONCE in a multi-turn message asking the model to shorten under 260 while keeping the hook. Wraps everything in try/catch and rethrows as `Failed to generate ${platform} post: ${message}`. Throws (does not silently fall back) so callers can decide. | +| **C.2** — Pipeline-driven `POST /api/posts/generate` route | Orchestrates: validate → auth → fetch `/api/timeline` internally → enrich → sanitize → pick anchor event → generate all platforms in parallel → return | `src/app/api/posts/generate/route.ts` *(rewritten — replaces 4.1 implementation)* | ✅ done | Strict body validation → 400. `getServerSession` → 401. Internal HTTP `fetch(${NEXTAUTH_URL ?? request origin}/api/timeline, { cookie })` per spec — replaces 4.1's direct Prisma + `lib/github` access. No matching entries → 400 `{ error: "No matching timeline entries found" }`. Multi-event handling: enriches + sanitizes ALL selected entries in parallel, then `pickAnchorEvent()` selects ONE by `(difficulty desc, dateIso desc)` — comment in source explains why concatenating events produces muddled posts. `Promise.all` over platforms → `claude-sonnet-4-6` via `generatePost`. Try/catch → 500 `{ error, detail }`. **Response shape change**: returns `{ posts: GeneratedPost[] }` with `characterCount` per post (was bare `GeneratedPost[]` in 4.1). | +| **C.3** — Retry wrapper + error resilience | `withRetry` utility + route updated to return partial success (`207`) or total failure (`500`) gracefully | `src/lib/postGenerator/withRetry.ts` *(new)*, `src/app/api/posts/generate/route.ts` *(edit)* | ✅ done | `withRetry(fn, retries=1, label="")` → tries `fn()`, on throw waits 500 ms and retries once, on second throw logs with label and returns `null` (never throws). Route wraps each `generatePost` call with `withRetry(..., 1, platform)`. After `Promise.all`, partitions results: `posts[]` (non-null) + `failed[]` (null → platform name). All failed → `500 { error, detail }`. Some failed → `207 { posts, failed }`. All success → `200 { posts }`. | +| **C.2-followup** — Fix generate page response parsing | One-line fix: destructure `{ posts }` from the new `{ posts: GeneratedPost[] }` response envelope | `src/app/(app)/generate/page.tsx` | ✅ done | Changed `(await res.json()) as GeneratedPost[]` → `const { posts: generated } = (await res.json()) as { posts: GeneratedPost[] }`. No re-roll handler exists in the UI. No other UI or state logic changed. | + +--- + +## Phase D — UI Components +**2026-05-04** + +| Task | Scope | Files | Status | Notes | +|------|-------|-------|--------|-------| +| **D.1** — Event selector component | Client component: selectable event cards with badge, title, truncated summary, date, checkbox | `src/components/postGenerator/EventSelector.tsx` *(new)* | ✅ done | Props: `entries: TimelineEntry[]`, `selectedIds: string[]`, `onChange: (ids: string[]) => void`. Per-card: type badge (amber=commit, violet=merged-PR, blue=open/closed-PR, emerald=release), title, summary truncated at 100 chars, `dateIso` formatted as "Mar 19". Checkbox top-right. Click anywhere on card toggles; selected = violet border highlight. "Select all / Deselect all" header row with live count. Empty state with Settings link. Keyboard-accessible (`role="checkbox"`, `tabIndex`, space/Enter handlers). Matches existing zinc/violet dark theme from `TimelineEntryCard`. | +| **D.2** — Options panel component | Client component: platform toggles, tone selector, privacy level selector | `src/components/postGenerator/OptionsPanel.tsx` *(new)* | ✅ done | Props: `selectedPlatforms`, `tone`, `privacyLevel`, `onPlatformsChange`, `onToneChange`, `onPrivacyChange`. Platforms: 3 toggle buttons with colored dot indicators (white=X, blue=LinkedIn, orange=Reddit), `aria-pressed`, multi-select. Tone: 2×4 grid of single-select buttons, each with label + one-line desc (desc tints violet when active). Privacy: 3-col grid, each level has its own active accent (emerald=high, amber=medium, red=low) so risk is visually encoded. "No platform selected" warning. Fully controlled — no internal state. | +| **D.3** — Results view component | Client component: per-platform post cards with editable textarea, live char counter, copy + regenerate actions | `src/components/postGenerator/ResultsPanel.tsx` *(new)* | ✅ done | Props: `posts[]` (platform/content/characterCount), `onRegenerate(platform)`, `regenerating[]`. Per-card: editable textarea auto-resizes to content via `scrollHeight` on every value change. Char counter: X shows `N / 280` in red if over 280, amber if >90% (252+), plain zinc otherwise; other platforms show raw count. Copy button: clipboard API, 2-second "Copied!" feedback with emerald tint + Check icon. Regenerate button: `RefreshCw` icon, spinner + disabled when in `regenerating[]`. Local `text` state per card; syncs from `content` prop via `useEffect` on regeneration. `ResultsPanel` renders `null` when `posts` is empty (clean no-op). | +| **D.4** — Generate page wired up | Full page assembly: `EventSelector` + `OptionsPanel` + `ResultsPanel` + generate + regenerate handlers | `src/app/(app)/generate/page.tsx` *(rewritten)* | ✅ done | Two-column layout (`lg:grid-cols-[1fr,1.25fr]`) — events left, options/results right; single column on mobile. Default state: `platforms=["linkedin"]`, `tone="casual"`, `privacyLevel="medium"`. Loading skeleton (3 pulsing cards) while timeline fetches. `handleGenerate`: posts to `/api/posts/generate`, handles 207 partial success (amber warning with failed platform names), scrolls to results via `requestAnimationFrame` + `scrollIntoView`. `handleRegenerate(platform)`: adds to `regenerating[]`, posts with single-platform array, merges result back via `Map` keyed by platform, removes from failures if now succeeded. Inline hints when button is disabled. Sidebar already had "Generate" nav item — no change needed. | + +--- + +## Phase 1 → 4 — GitHub Integration Rework +**2026-05-01 → 2026-05-02** · Full checklist: `docs/todo-github-integration.md` + +| Date | Task | Scope | Files | Status | Notes | +|------|------|-------|-------|--------|-------| +| **2026-05-01** | **1.1** — Prisma schema + env setup | Confirm schema correctness, migration state, Prisma client | `prisma/schema.prisma`, `prisma/migrations/`, `.env` *(created)* | ✅ done | Schema correct. 1 migration applied. Created `.env` so Prisma CLI auto-loads `DATABASE_URL`. | +| **2026-05-02** | **1.2** — NextAuth config audit | Confirm `authOptions`, provider, adapter, session callback | `src/lib/auth.ts`, `src/app/api/auth/[...nextauth]/route.ts`, `src/types/next-auth.d.ts` | ✅ done | Restored `.trim()` on `clientId`, `clientSecret`, `secret`. Added `logger` + `events` debug blocks. | +| **2026-05-02** | **1.3** — GitHub API helper audit | Confirm `fetchUserRepos` + `fetchRepoCommits` endpoints, headers, error guards | `src/lib/github/api.ts`, `types.ts`, `index.ts` | ✅ done | `perPage` default raised 30→50. Added token-missing guard to both functions. | +| **2026-05-02** | **1.4** — GitHub API route audit | Confirm all 5 routes use `getServerSession`, correct Prisma models, error handling | `src/app/api/github/status/`, `repos/`, `commits/`, `select-repo/`, `disconnect/` | ✅ done | All correct. No changes needed. `disconnect` clears `selectedGithubRepo` only — Account row preserved. | +| **2026-05-02** | **2.1** — `fetchRepoPullRequests` helper | Fetch closed PRs from GitHub API | `src/lib/github/api.ts`, `types.ts`, `index.ts` | ✅ done | `GET /repos/{owner}/{repo}/pulls?state=closed&sort=updated&per_page=20`. `GitHubPullRequest` type added. | +| **2026-05-02** | **2.2** — `fetchRepoReleases` helper | Fetch releases (high-signal events) from GitHub API | `src/lib/github/api.ts`, `types.ts`, `index.ts` | ✅ done | `GET /repos/{owner}/{repo}/releases?per_page=10`. `GitHubRelease` type added. | +| **2026-05-02** | **2.3** — GitHub event normalizer | Map raw commits/PRs/releases → `TimelineEntry` shape | `src/lib/github/normalizeEvents.ts` *(new)* | ✅ done | Exports `normalizeCommit`, `normalizePullRequest`, `normalizeRelease`. PR state derived from `merged_at`. Summaries truncated at 500 chars. | +| **2026-05-02** | **3.1** — `GET /api/timeline` route | Combined endpoint: commits + PRs + releases as `TimelineEntry[]` | `src/app/api/timeline/route.ts` *(new)* | ✅ done | Auth + token guards → 401. `Promise.all` parallel fetch. Normalizes → merges → sorts by `dateIso` desc. Try/catch → 500. | +| **2026-05-02** | **3.2** — Timeline page wired to `/api/timeline` | Replace inline GitHub fetch + demo fallback | `src/app/(app)/timeline/page.tsx` | ✅ done | Removed `fetchRepoCommits`, `TIMELINE_ENTRIES`, `mapCommitsToTimeline`. Forwards session cookie via `headers()`. Empty state with Settings link. | +| **2026-05-02** | **4.1** — `POST /api/posts/generate` route | LLM post generation from selected timeline entries via Anthropic | `src/app/api/posts/generate/route.ts` *(new)* | ✅ done | Validates `timelineEntryIds`, `platforms`, `tone`, `privacyLevel`. Calls `claude-opus-4-7`, `max_tokens: 1000`. Robust JSON extraction. Returns `[{platform, content}]`. | +| **2026-05-02** | **4.2** — Post generator UI | Select events, pick platforms/tone/privacy, trigger generation, edit drafts | `src/app/(app)/generate/page.tsx` *(new)*, `src/components/layout/Sidebar.tsx` | ✅ done | `"use client"`. Checkbox event list. Toggle buttons for platforms/tone/privacy. Generate button disabled until ≥1 event + ≥1 platform. Editable textareas per platform. "Generate" added to sidebar nav. | + +--- + +## Auth Setup & Verification +**2026-05-02 → 2026-05-03** -- what branch is active, -- what area is being changed, -- which files or folders are expected to be touched, -- and any API or integration assumptions. +| Date | What | Outcome | +|------|------|---------| +| **2026-05-02** | GitHub OAuth App credentials added to `.env.local` — `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `NEXTAUTH_SECRET` | All env vars confirmed present | +| **2026-05-02** | `NEXTAUTH_URL` corrected from `3000` → `3001` (dev server port conflict) | `http://localhost:3001` — matches GitHub callback URL | +| **2026-05-03** | End-to-end auth verification — sign-in flow, DB records, `access_token` | User + Account + Session created in DB. `access_token` starts with `gho_`. Scopes: `read:user, repo, user:email`. Session valid to 2026-06-02. | +| **2026-05-03** | Debug code added then cleaned up | `[ENV CHECK]` log + `/api/debug-session` endpoint removed after verification | -## Active Work +--- -| Branch | Owner / Chat | Scope | Files Touched | Status | Notes | -|--------|---------------|-------|----------------|--------|-------| -| `feature/dev-timeline-ui` | TimelineUi chat | Timeline feature types, data, hook, app shell restoration, and minimal dashboard/settings routes so the sidebar menus render across the app | `src/features/timeline/types.ts`, `src/features/timeline/data.ts`, `src/features/timeline/useTimelineFilter.ts`, `src/components/timeline/TimelineEntryCard.tsx`, `src/components/timeline/TimelineFilters.tsx`, `src/components/timeline/TimelineList.tsx`, `src/components/timeline/TimelineView.tsx`, `src/components/timeline/index.ts`, `src/components/layout/Topbar.tsx`, `src/components/layout/Sidebar.tsx`, `src/lib/utils/cn.ts`, `src/app/(app)/layout.js`, `src/app/(app)/timeline/page.tsx`, `src/app/(app)/dashboard/page.tsx`, `src/app/(app)/settings/page.tsx`, `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/globals.css`, `package.json`, `tsconfig.json`, `postcss.config.js`, `tailwind.config.ts` | **in progress** | Fake data, live search + type filter, date groups. Restored missing Next/Tailwind root config and reintroduced the app shell so `/dashboard`, `/timeline`, and `/settings` render with menus. | +## Completed Features -## Completed +| Merged | Branch | Scope | +|--------|--------|-------| +| **2026-03-16** | `feature/landing-page` | Full marketing landing page — PR #2 | +| **2026-03-18** | `feature/app-shell` | Sidebar, Topbar, `(app)` route-group layout, dashboard/settings placeholders — PR #3 | +| **2026-03-19** | `feature/dev-timeline-ui` | Timeline types, fake data, filters, app shell restoration, dashboard/settings/timeline routes | -| Branch | Merged | Scope | Notes | -|--------|--------|-------|-------| -| `feature/app-shell` | Mar 18, 2026 → `main` | Sidebar, Topbar, (app) route-group layout, dashboard/settings placeholders | PR #3 | -| `feature/landing-page` | Mar 16, 2026 → `main` | Full marketing landing page | PR #2 | +--- ## Update Template -Copy this row format for new feature branches: +When closing work, add a row with the **exact date** (`YYYY-MM-DD`). Use *(estimate)* only if the date is genuinely unknown. -| `feature/your-feature` | Chat name or owner | Short description of the feature | `src/features/...`, `src/components/...` | planned / in progress / blocked / done | Any dependencies or assumptions | +| Date | Task | Scope | Files | Status | Notes | +|------|------|-------|-------|--------|-------| +| **YYYY-MM-DD** | Short task name | One-line description | `src/...` | ✅ done / 🔲 pending / 🔄 in progress | Key decisions, fixes, caveats | diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 899aed8..fa89da1 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -2,65 +2,441 @@ This file tracks the public and internal API shapes that multiple features depend on. -Keep this updated whenever a route contract changes so parallel chats do not drift. +**Keep this updated whenever a route contract changes** so parallel chats and agents do not drift. + +--- + +## Authentication (NextAuth.js) + +> Added: **~Mar–Apr 2026** + +Sign-in is handled by **NextAuth** under: + +- `GET/POST /api/auth/*` (e.g. `GET /api/auth/signin/github`, `GET /api/auth/callback/github`) + +GitHub OAuth App **Authorization callback URL** must be: + +- Local: `http://localhost:3000/api/auth/callback/github` +- Production: `https:///api/auth/callback/github` + +Required env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `DATABASE_URL` + +User sessions and GitHub access tokens are stored via the **Prisma adapter** (database), not session cookies. + +--- + +## Implemented Endpoints + +### `GET /api/github/status` + +> Added: **~Mar–Apr 2026** · Handler: `src/app/api/github/status/route.ts` + +Returns the current GitHub connection status for the signed-in user. + +```ts +type GitHubConnectionStatus = { + connected: boolean; + user: { login: string; avatarUrl: string } | null; + selectedRepo: string | null; +}; +``` + +--- + +### `GET /api/github/repos` + +> Added: **~Mar–Apr 2026** · Handler: `src/app/api/github/repos/route.ts` + +Fetches the authenticated user's GitHub repositories, sorted by most recently pushed. + +Requires: signed-in NextAuth session + valid GitHub `access_token` on the user's `Account` row. + +Response: Array of GitHub repository objects. + +--- + +### `GET /api/github/repos/[owner]/[repo]/commits` + +> Added: **~Mar–Apr 2026** · Handler: `src/app/api/github/repos/[owner]/[repo]/commits/route.ts` + +Fetches recent commits for a specific repository. Returns up to 20 commits. + +Response: Array of GitHub commit objects. + +--- + +### `POST /api/github/select-repo` + +> Added: **~Mar–Apr 2026** · Handler: `src/app/api/github/select-repo/route.ts` + +Persists the selected repository for the signed-in user (`User.selectedGithubRepo` in the database). Used by the repo picker UI. + +Request body: + +```ts +{ repo: string | null } +``` + +--- + +### `POST /api/github/disconnect` + +> Added: **~Mar–Apr 2026** · Handler: `src/app/api/github/disconnect/route.ts` + +Clears `User.selectedGithubRepo` only — stops tracking a repository without removing the GitHub OAuth link. +To fully end the session use **Sign out**. + +--- + +### `GET /api/timeline` + +> Added: **~May 2026** · Handler: `src/app/api/timeline/route.ts` + +Returns timeline entries for the authenticated user's selected GitHub repo. Fetches commits, PRs, and releases in parallel, normalizes them, and returns the list sorted by date descending. + +**Auth:** requires NextAuth session + GitHub access token on the `Account` row (either missing → 401). + +**Response — success (`200`):** `TimelineEntry[]` sorted by `dateIso` descending. + +```ts +// Full source: src/features/timeline/types.ts +// This is a discriminated union — not a flat object. + +type PostStatus = "published" | "draft" | null; +type PrState = "open" | "merged" | "closed"; + +interface BaseEntry { + id: string; + title: string; + summary: string; + repo: string; + branch: string; + dateIso: string; // ISO 8601 — used for grouping and sorting + displayTime: string; // human-readable label shown in the card + postStatus: PostStatus; +} + +interface CommitEntry extends BaseEntry { + type: "commit"; + hash: string; + filesChanged: number; + additions: number; + deletions: number; +} + +interface PrEntry extends BaseEntry { + type: "pr"; + prNumber: number; + state: PrState; + labels: string[]; + commits: number; + filesChanged: number; +} + +interface ReleaseEntry extends BaseEntry { + type: "release"; + version: string; + highlights: string[]; + commits: number; +} + +type TimelineEntry = CommitEntry | PrEntry | ReleaseEntry; +``` + +**Other responses:** +- `200` with `[]` if no repo is selected. +- `400` if `selectedGithubRepo` is not a valid `owner/repo` string. +- `500` with `{ error: "Failed to fetch timeline" }` on unexpected failure. + +> ⚠️ An older draft of this contract listed a flat shape with `repoName`, `eventType`, `createdAt`, and `privacyLevel`. That shape was never implemented and has been removed. + +--- + +### `POST /api/posts/generate` + +> Added: **~May 2026** (Task 4.1) · **Rewritten: 2026-05-04** (Task C.2) · Handler: `src/app/api/posts/generate/route.ts` + +Generates platform-specific draft posts from one or more selected timeline entries by running the full post-generator pipeline: + +1. Validate body → `400` on malformed input. +2. `getServerSession` → `401` if no session. +3. Internal HTTP `GET /api/timeline` (session cookie forwarded). Failure here surfaces as `500` with `detail`. +4. Filter entries by `timelineEntryIds`. If nothing matches → `400 { error: "No matching timeline entries found" }`. +5. `enrichEvent()` + `sanitizeEvent()` over **all** matched entries in parallel. +6. `pickAnchorEvent()` chooses one event to drive generation: highest `difficulty` first, then most recent `dateIso`. *(Why one anchor: see source comment — concatenating multiple events produces muddled, multi-topic posts.)* +7. `Promise.all` over `platforms` → `generatePost(anchor, platform, tone)` → Anthropic `claude-sonnet-4-6`. + +**Auth:** requires NextAuth session (missing → `401`). + +**Request body:** + +```ts +type GeneratePostsRequest = { + timelineEntryIds: string[]; // non-empty + platforms: Array<"x" | "linkedin" | "reddit">; // non-empty subset + tone: "casual" | "professional" | "feedback-seeking" | "educational"; + privacyLevel: "high" | "medium" | "low"; +}; +``` + +**Response — success (`200`):** + +```ts +type GeneratedPost = { + platform: "x" | "linkedin" | "reddit"; + content: string; + characterCount: number; +}; + +type GeneratePostsResponse = { + posts: GeneratedPost[]; +}; +``` + +> ⚠️ **Breaking change vs. Task 4.1.** The previous handler returned a **bare** `GeneratedPost[]` without `characterCount`. C.2 wraps the array in `{ posts }` and adds `characterCount` to each post. Any consumer reading the old shape needs to update. + +**Other responses:** + +| Status | Body | When | +|---|---|---| +| `400` | `{ error: "Invalid request body" }` | Body fails schema validation | +| `400` | `{ error: "No matching timeline entries found" }` | `timelineEntryIds` filter yields no entries from `/api/timeline` | +| `401` | `{ error: "Unauthorized" }` | No NextAuth session | +| `207` | `{ posts: GeneratedPost[], failed: Platform[] }` | Some but not all platforms generated successfully (partial success) | +| `500` | `{ error: "Failed to generate posts", detail: string }` | All platform generations failed, internal `/api/timeline` error, or any other thrown error | + +**Retry behavior (Task C.3):** each platform generation is wrapped in `withRetry(..., 1, platform)` — on failure it waits 500 ms and retries once before logging and returning `null`. Platforms that return `null` end up in the `failed` array. + +Required env: `ANTHROPIC_API_KEY` · also reads `NEXTAUTH_URL` to compute the internal `/api/timeline` base URL (falls back to the incoming request's origin). + +--- ## Planned Endpoints ### `POST /api/github/webhook` -Purpose: -- Receive GitHub webhook events. -- Validate signatures. -- Normalize events into Devlog's internal event format. +> Status: **not yet implemented** + +Receives GitHub webhook events, validates signatures, and normalizes them into Devlog's internal event format. Expected request: -- GitHub webhook payload -- GitHub event headers +- GitHub webhook payload + event headers Expected response: - `200 OK` for accepted events - `4xx` for invalid signatures or malformed payloads -### `GET /api/timeline` +--- -Purpose: -- Return timeline entries for the authenticated user. +## Internal Modules -Expected response shape: +These are not HTTP endpoints but shared library contracts that multiple features depend on. + +--- + +### `src/lib/postGenerator/enrichEvent.ts` + +> Added: **May 4, 2026** + +Exports `EnrichedEvent` type and `enrichEvent(entry: TimelineEntry): Promise`. + +Calls Anthropic `claude-haiku-4-5-20251001` as a fast pre-pass to expand raw event data before post generation. ```ts -type TimelineEntry = { - id: string; - repoName: string; - eventType: "commit" | "pull_request" | "release" | "milestone"; - title: string; - summary: string; - privacyLevel: "high" | "medium" | "low"; - createdAt: string; +type EnrichmentDifficulty = "trivial" | "moderate" | "significant"; + +type EnrichedEvent = { + originalEntry: TimelineEntry; + whatChanged: string; // what was actually built or fixed + whyItMatters: string; // user-facing or developer-facing impact + technicalDetail: string; // implementation specifics (filtered by privacy level downstream) + outcome: string; // what is now possible that wasn't before + difficulty: EnrichmentDifficulty; }; ``` -### `POST /api/posts/generate` +**Fallback behavior:** if `ANTHROPIC_API_KEY` is missing, Anthropic returns non-2xx, or JSON parsing fails, all text fields fall back to values from the entry (`title`, `summary`). The function never throws. -Purpose: -- Generate platform-specific drafts from one or more timeline events. +--- -Expected request shape: +### `src/lib/postGenerator/sanitizeEvent.ts` -```ts -type GeneratePostsRequest = { - timelineEntryIds: string[]; - platforms: Array<"x" | "linkedin" | "reddit">; - tone: "casual" | "professional" | "feedback-seeking" | "educational"; - privacyLevel: "high" | "medium" | "low"; -}; +> Added: **May 4, 2026** + +Exports `sanitizeEvent(event: EnrichedEvent, privacyLevel: "high" | "medium" | "low"): EnrichedEvent`. + +Pure, synchronous — no AI calls, no async. + +| Privacy level | Behavior | +|---|---| +| `"high"` | Blanks `technicalDetail`. Redacts file paths, function calls, variable-like tokens, and error messages in `whatChanged` and `outcome` → `[internal detail]`. | +| `"medium"` | Replaces `technicalDetail` with `"Implementation details hidden."`. Strips inline code, fenced code blocks, and stack-trace lines from all text fields. | +| `"low"` | Returns the event unchanged. | + +--- + +### `src/lib/postGenerator/templates/linkedinTemplate.ts` + +> Added: **May 4, 2026** + +Exports `buildLinkedInPrompt(event: EnrichedEvent, tone: string): string`. + +Pure, synchronous — builds the full prompt string used to generate a single LinkedIn draft. Does not call any model. + +**Prompt enforces:** + +- 150–300 word limit. +- Five-part story structure: (1) problem/challenge hook, (2) what was built and how, (3) why it matters / what changed, (4) takeaway, (5) soft closing line. +- ≤2 hashtags, on the final line only. +- Blocks hype openers (`"I am excited to share"`, `"Thrilled to announce"`, etc.). +- Returns post text only — no preamble, no surrounding quotes, no Markdown fences. + +**Tone-specific guidance:** + +| Tone | Effect | +|---|---| +| `"casual"` | First person, conversational. Soft `"check it out"` close allowed. | +| `"professional"` | Reflective, measured, no slang or CTAs. | +| `"feedback-seeking"` | Ends with a genuine open question. | +| `"educational"` | Centers a broadly applicable lesson. | +| *anything else* | Falls back to a neutral, reflective tone. | + +**Privacy interaction:** if `event.technicalDetail` is empty (high-privacy sanitization), the prompt instructs the model not to invent specifics and to keep the post outcome-focused. + +--- + +### `src/lib/postGenerator/templates/xTemplate.ts` + +> Added: **May 4, 2026** *(replaced earlier B.3* stub on the same date)* + +Exports `buildXPrompt(event: EnrichedEvent, tone: string): string`. + +Pure, synchronous — builds the full prompt string used to generate a single X (Twitter) draft. Does not call any model. + +The prompt's central principle: on X, the first 8 words decide whether someone keeps reading. Hook carries the post; everything else supports it. + +**Hard requirements enforced by the prompt:** + +- **Maximum 260 characters** total, including hashtags. (X's hard limit is 280; the 20-character buffer is for safety.) +- The first sentence is the hook — the single most interesting or surprising thing about the event. +- The hook **must NOT** start with `"I"`, `"We"`, or `"Just"`. Lead with the OUTCOME or the PROBLEM. +- After the hook, at most **1–2 short follow-up lines**. +- **0–2 hashtags**, relevant and specific. Generic spam tags are explicitly banned: `#coding`, `#developer`, `#tech`, `#programming`, `#software`. +- Hype openers blocked (`"Excited to share"`, `"Thrilled to announce"`, `"Big news"`, etc.). +- Single post — never a thread. +- Self-check loop in the prompt: count characters before responding; if over 260, rewrite shorter and count again. Also re-verify the first word is not `"I"`, `"We"`, or `"Just"`. +- Returns post text only — no preamble, no character count, no surrounding quotes, no Markdown fences. + +**Tone-specific guidance:** + +| Tone | Effect | +|---|---| +| `"casual"` | Punchy, direct, sentence fragments allowed. | +| `"professional"` | Complete sentences, measured wording, no slang. | +| `"feedback-seeking"` | Ends with one short genuine question (≤8 words). | +| `"educational"` | Leads with the insight — the takeaway IS the hook. | +| *anything else* | Neutral, punchy, factual. | + +**Inputs used:** only `whatChanged`, `outcome`, and `difficulty` (intentionally narrower than LinkedIn/Reddit — keeps the post tight and avoids accidental detail leaks). `whyItMatters` and `technicalDetail` are not injected. + +**`difficulty` calibration:** + +| Value | Effect | +|---|---| +| `"significant"` | Worth letting the post reflect that, but never inflate. | +| `"moderate"` | Match the size of the change without overselling. | +| `"trivial"` | Keep the post understated — do not make a small change sound heroic. | + +--- + +### `src/lib/postGenerator/templates/redditTemplate.ts` + +> Added: **May 4, 2026** + +Exports `buildRedditPrompt(event: EnrichedEvent, tone: string): string`. + +Pure, synchronous — builds the full prompt string used to generate a single Reddit draft for r/programming, r/webdev, or r/devlog. Does not call any model. + +**Output format enforced by the prompt:** + +``` + +<one blank line> +<body — flowing prose, paragraph breaks> ``` -Expected response shape: +No hashtags, no Markdown code fences, no surrounding quotes. + +**Body structure required:** + +1. What I was trying to do. +2. What actually happened (including struggles or surprises). +3. What I learned or what's next. +4. *(Optional)* One genuine question for the community — required if tone is `"feedback-seeking"`. + +**Voice rules (apply regardless of tone):** + +- Always lean honest and self-aware — Reddit downvotes marketing language. +- Banned vocabulary: `excited`, `thrilled`, `game-changer`, `journey`, `passionate`, `leverage`, `synergy`, `revolutionary`, `cutting-edge`, `delighted`, `stoked`, plus startup/LinkedIn-style phrasing in general. + +**Tone-specific guidance:** + +| Tone | Effect | +|---|---| +| `"casual"` | More informal, light self-deprecating humor allowed. | +| `"professional"` | Measured and structured — still honest, no jokes. | +| `"feedback-seeking"` | Body must end with a specific technical question. | +| `"educational"` | Frames the post as a lesson someone less experienced could learn from. | +| *anything else* | Falls back to neutral, factual, self-aware. | + +**Inputs used:** all five enriched fields — `whatChanged`, `whyItMatters`, `technicalDetail`, `outcome`, and `difficulty` (the last calibrates how big a deal the post should sound; `"trivial"` should not sound heroic). + +**Privacy interaction:** if `event.technicalDetail` is empty (high-privacy sanitization), the prompt forbids inventing specifics, file names, or stack traces and keeps the post about experience and outcome. + +--- + +### `src/lib/postGenerator/withRetry.ts` + +> Added: **May 4, 2026** + +Exports `withRetry<T>(fn: () => Promise<T>, retries?: number, label?: string): Promise<T | null>`. + +Generic retry wrapper used by `POST /api/posts/generate` to isolate per-platform failures without crashing the whole request. + +**Behavior:** +- Calls `fn()`. +- On throw: waits **500 ms**, then retries up to `retries` times (default `1` — so 2 total attempts). +- If all attempts throw: logs `[withRetry:${label}] failed after N attempt(s): ${message}` and returns `null` (does **not** rethrow). +- On success: returns the resolved value of `fn()`. + +**Returns `null` instead of throwing** — this is deliberate. Callers (the route) check for `null` to decide between partial success (`207`) and total failure (`500`). + +--- + +### `src/lib/postGenerator/generatePost.ts` + +> Added: **May 4, 2026** + +Exports the `Platform` type, the `GeneratedPost` type, and the async function `generatePost(event, platform, tone): Promise<GeneratedPost>`. ```ts +type Platform = "x" | "linkedin" | "reddit"; + type GeneratedPost = { - platform: "x" | "linkedin" | "reddit"; - content: string; + platform: Platform; + content: string; + characterCount: number; }; ``` + +**Behavior:** + +1. Reads `ANTHROPIC_API_KEY` from env (trimmed); throws if missing. +2. Selects the prompt builder for the platform via an exhaustive switch (`buildLinkedInPrompt` / `buildXPrompt` / `buildRedditPrompt`). +3. Calls `POST https://api.anthropic.com/v1/messages` with model `claude-sonnet-4-6`, `max_tokens: 1000`, headers `x-api-key` + `anthropic-version: 2023-06-01`. +4. Extracts text from `content[].text` items, trims, and strips Markdown code fences and any surrounding straight or curly quotes. +5. **X retry:** if `platform === "x"` and the cleaned content exceeds **280** characters, makes one follow-up call that includes the original prompt, the previous assistant response, and a user message: + *"The previous response was N characters. Shorten it to under 260 characters while keeping the hook."* Retries **once only**. +6. Returns `{ platform, content, characterCount: content.length }`. + +**Error handling:** every failure path (missing API key, non-2xx Anthropic response, empty content, network throw) is wrapped in `try/catch` and rethrown as `Error("Failed to generate ${platform} post: ${message}")`. The function does **not** silently fall back to placeholder text — callers decide how to surface the error. diff --git a/docs/architecture.md b/docs/architecture.md index 437788d..b8dc9b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,9 +1,22 @@ # Architecture +| | | +|---|---| +| **Last reviewed** | **2026-05** | +| **Related** | [`CHANGELOG.md`](./CHANGELOG.md) · [`agent-worklog.md`](./agent-worklog.md) | + ## Goal Keep Devlog easy to ship as a single Next.js application today while preserving a clean path to a future monorepo split if backend complexity grows. +## Significant updates (dated) + +| Date | Summary | +|------|---------| +| **2026-05** | GitHub pipeline: PR/release fetch, event normalizer, **`GET /api/timeline`**, timeline UI wired to API. Post generator: **`POST /api/posts/generate`**, generate UI, enrich + sanitize layer. | +| **2026-03** | Landing page, app shell, timeline UI merged to `main` (see [`CHANGELOG.md`](./CHANGELOG.md)). | +| **2026-03** | ADR-0001: single-app structure with `src/app` / `components` / `features` / `lib` / `server` (see [`decisions/ADR-0001-project-structure.md`](./decisions/ADR-0001-project-structure.md)). | + ## Current Structure Choice Option A: single application with clear internal boundaries. @@ -25,12 +38,9 @@ Option A: single application with clear internal boundaries. - `github` - OAuth, repo selection, webhook ingestion, event normalization. -- `timeline` ← **implemented (fake data)** +- `timeline` - Internal devlog feed, timeline grouping, progress views. - - `types.ts` — `CommitEntry | PrEntry | ReleaseEntry` discriminated union, `PostStatus`, `PrState`, `TimelineFilterType`. - - `data.ts` — fake entries; swap for API-fetched data without touching UI. - - `useTimelineFilter.ts` — client-side search + type filter hook with per-tab counts. - - UI components live in `src/components/timeline/`: `TimelineView`, `TimelineList`, `TimelineEntryCard`, `TimelineFilters`. + - Current implementation includes `types.ts`, `data.ts`, `useTimelineFilter.ts`, and UI components in `src/components/timeline/`; live data from **`GET /api/timeline`** as of **2026-05**. - `privacy` - Repo privacy levels, sanitization rules, post visibility policies. - `post-generator` diff --git a/docs/decisions/ADR-0001-project-structure.md b/docs/decisions/ADR-0001-project-structure.md new file mode 100644 index 0000000..5dc8641 --- /dev/null +++ b/docs/decisions/ADR-0001-project-structure.md @@ -0,0 +1,50 @@ +# ADR-0001: Initial Project Structure + +| | | +|---|---| +| **Status** | Accepted | +| **Decided** | **2026-03** (aligned with first `main` merges: landing → app shell → timeline) | +| **Supersedes** | — | + +## Context + +Devlog needs a structure that is: + +- fast to start with, +- friendly to solo development, +- easy for multiple agent chats to work in parallel, +- and able to evolve into a larger architecture later. + +## Decision + +Use a single-app Next.js structure with internal domain boundaries: + +- `src/app` for routes and route handlers +- `src/components` for reusable UI +- `src/features` for domain modules +- `src/lib` for shared helpers and infrastructure clients +- `src/server` for business logic and server-only code +- `docs/agent-worklog.md` as the required coordination layer for feature branches +- `docs/CHANGELOG.md` for dated product/documentation milestones (see **2026-05** changelog) + +## Consequences + +### Positive + +- Low setup overhead +- Fast MVP iteration +- Clear boundaries for feature-based work +- Easy migration path to a monorepo later + +### Negative + +- Backend and frontend will still live in one application at first +- Strong discipline is required to avoid pushing all logic into route files + +## Follow-up + +Revisit this decision after `v0.2.0` if: + +- webhook/AI workloads become complex, +- multiple services are needed, +- or multiple contributors are working concurrently for an extended period. diff --git a/docs/launch-log.md b/docs/launch-log.md new file mode 100644 index 0000000..ddb6b23 --- /dev/null +++ b/docs/launch-log.md @@ -0,0 +1,320 @@ +# Launch Log + +Tracks launch-phase milestones across all phases. Agents check boxes and append to "Agent Log" sections as steps complete. + +--- + +## Phase 1 — Database Migration (SQLite → Supabase PostgreSQL) + +| Step | Description | Status | +|------|-------------|--------| +| 1.1 | Read current state (schema, .env, prisma.ts, package.json) | ✅ done | +| 1.2 | Update `datasource` block in `schema.prisma` to `postgresql` + `directUrl` | ✅ done | +| 1.3 | Audit model fields — add `@db.Text` to OAuth token fields | ✅ done | +| 1.4 | Verify `.env` has `DATABASE_URL` and `DIRECT_URL` | ✅ done | +| 1.5 | Run `prisma migrate dev --name switch-to-postgres` | ✅ done | +| 1.6 | Run `prisma generate` + `next build` — confirm no Prisma errors | ✅ done | + +--- + +### Agent Log — Phase 1 + +**Date:** 2026-05-29 +**Branch:** `cursor/sqlite-to-supabase-postgres-2f13` + +#### Files changed + +| File | Change | +|------|--------| +| `prisma/schema.prisma` | `datasource` provider changed `sqlite` → `postgresql`; `directUrl = env("DIRECT_URL")` added; `Account.refresh_token`, `access_token`, `id_token` annotated with `@db.Text` | +| `.env` | **Created** (did not previously exist) — placeholder Supabase URLs inserted; needs real values from Supabase dashboard | +| `.env.example` | Updated `DATABASE_URL` comment + value from SQLite `file:./prisma/dev.db` → PostgreSQL pooler URL format; `DIRECT_URL` entry added | + +#### Field audit results + +**Account model:** +- `refresh_token String?` → `String? @db.Text` ✅ — OAuth refresh tokens exceed `varchar(191)` on Postgres +- `access_token String?` → `String? @db.Text` ✅ — GitHub PATs / OAuth tokens can exceed 191 chars +- `id_token String?` → `String? @db.Text` ✅ — OIDC JWTs are long +- All other `String?` fields (`token_type`, `scope`, `session_state`) are short-value fields — no `@db.Text` needed + +**DateTime fields:** +- `Session.expires`, `User.emailVerified`, `VerificationToken.expires` — all use `DateTime` with no string defaults ✅ + +**Boolean fields:** +- None present in schema ✅ + +**Long text content fields (summaries, bodies):** +- Schema has no post/body content fields at this time — no additional `@db.Text` needed ✅ + +**NextAuth adapter table completeness:** +- `User` ✅ — `id`, `name`, `email`, `emailVerified`, `image`, `accounts`, `sessions` +- `Account` ✅ — all 12 required fields present +- `Session` ✅ — `id`, `sessionToken`, `userId`, `expires`, `user` relation +- `VerificationToken` ✅ — `identifier`, `token`, `expires`, `@@unique([identifier, token])` + +#### Step 1.4 — .env status + +**✅ COMPLETE.** + +Real Supabase connection strings added to `.env` on **2026-05-30**: +- `DATABASE_URL` — Transaction pooler (`aws-1-us-east-2.pooler.supabase.com:6543`, IPv4 compatible) +- `DIRECT_URL` — Session pooler (`aws-1-us-east-2.pooler.supabase.com:5432`, IPv4 compatible) + +Note: The Supabase dashboard shows the direct connection (port 5432 on `db.tziipxidkvyijwwvqrdl.supabase.co`) as "Not IPv4 compatible." The session pooler on the same host as the transaction pooler was used for `DIRECT_URL` instead — this is the correct approach for Prisma migrations when not on an IPv6 or IPv4-addon plan. The `&` in the password was percent-encoded as `%26` in both URLs. + +#### Step 1.5 — Migration result + +**✅ COMPLETE.** + +The old SQLite migration history (`prisma/migrations/20260326051942_init/`) was removed since Prisma cannot migrate across provider types. A new PostgreSQL-native migration was created from scratch: + +``` +migrations/ + └─ 20260530210226_init_postgres/ + └─ migration.sql +``` + +Migration applied successfully to Supabase. All 4 NextAuth tables created with correct PostgreSQL types: +- `Account` — `refresh_token`, `access_token`, `id_token` as `TEXT` (from `@db.Text`) +- `Session` — `expires` as `TIMESTAMP(3)` +- `User` — `emailVerified` as `TIMESTAMP(3)`, `selectedGithubRepo` as `TEXT` +- `VerificationToken` — `expires` as `TIMESTAMP(3)`, both unique indexes applied + +`npx next build` — ✅ passes cleanly after migration. + +#### Step 1.6 — prisma generate + next build + +- `npx prisma generate` — **✅ succeeded** — Prisma Client v5.22.0 generated from updated schema +- `npx next build` — **✅ succeeded** — compiled all 16 routes with zero Prisma-related errors + +**Non-Prisma issues found during build (not fixed per instructions):** +- `[ENV CHECK]` debug log fires at static-page generation time — leftover debug code that `docs/agent-worklog.md` records as removed but is still present +- `/api/debug-session` route still exists in the build — same worklog entry says it was removed after auth verification but it remains + +--- + +## Phase 2 — Production Hardening Audit + +| Step | Description | Status | +|------|-------------|--------| +| 2.1 | Audit A — hardcoded localhost URLs | ✅ done | +| 2.2 | Audit B — sensitive data in console.log | ✅ done | +| 2.3 | Audit C — missing error boundaries in API routes | ✅ done (report only) | +| 2.3 | Audit D — exposed stack traces in API responses | ✅ done | +| 2.3 | Audit E — dead imports and unused files | ✅ done (report only) | +| 2.3 | Audit F — TypeScript `any` in critical files | ✅ done | +| 2.3 | Audit G — environment variable coverage | ✅ done | +| 2.4 | Fix pass — apply all required fixes | ✅ done | + +--- + +### Agent Log — Phase 2 + +**Date:** 2026-05-29 +**Branch:** `cursor/production-hardening-audit-2f13` + +#### Files changed + +| File | Change | +|------|--------| +| `src/app/api/auth/[...nextauth]/route.ts` | Wrapped `console.log("[ENV CHECK]", {...})` in `if (process.env.NODE_ENV !== "production")` guard | +| `src/lib/auth.ts` | Wrapped `console.log("[NextAuth signIn]", message)` in `if (process.env.NODE_ENV !== "production")` guard | +| `src/app/api/posts/generate/route.ts` | Replaced `detail: String(error)` with `NODE_ENV`-conditional: returns the raw string in dev, `"See server logs for details."` in production | + +--- + +#### Audit A — Hardcoded localhost URLs + +**Result: NONE FOUND.** + +`rg "localhost" src/` returned zero matches. No hardcoded localhost fetch calls anywhere in `src/`. + +--- + +#### Audit B — Sensitive data in console.log + +**2 issues found and fixed:** + +| File | Line | Statement | Problem | Fix applied | +|------|------|-----------|---------|-------------| +| `src/app/api/auth/[...nextauth]/route.ts` | 4 | `console.log("[ENV CHECK]", { hasClientId, hasClientSecret, hasSecret, nextAuthUrl, hasDatabaseUrl })` | Fires at module load time (including during `next build`); logs `NEXTAUTH_URL` value in plain text | Wrapped in `NODE_ENV !== "production"` guard | +| `src/lib/auth.ts` | 46 | `console.log("[NextAuth signIn]", message)` | NextAuth's `signIn` event `message` object contains `{ user, account, profile, isNewUser }`. The `account` field includes `access_token`, `refresh_token`, and `id_token` — live OAuth tokens written to logs on every sign-in | Wrapped in `NODE_ENV !== "production"` guard | + +All other `console.log` and `console.error` calls reviewed: +- `[generate] step N` logs — log primitive values (booleans, counts, platform strings, IDs); no tokens or user PII +- `[generatePost] calling API for platform:` — logs platform name only +- `[enrichEvent]` error logs — log error messages, not tokens +- `[withRetry]` error log — logs error message string, not tokens +- `[NextAuth Error]` error log — appropriate; NextAuth error metadata does not contain raw tokens + +--- + +#### Audit C — Missing error boundaries + +**Report only — no fixes applied.** + +The following API route handlers have no top-level try/catch and will return an unhandled 500 if their Prisma calls throw: + +| File | Missing coverage | +|------|-----------------| +| `src/app/api/debug-session/route.ts` | Entire handler — no try/catch at all | +| `src/app/api/github/disconnect/route.ts` | `prisma.user.update` can throw (DB error, connection failure) | +| `src/app/api/github/select-repo/route.ts` | `request.json()` can throw on malformed body; `prisma.user.update` can throw | +| `src/app/api/github/status/route.ts` | `getGithubAccessTokenForUser` + `prisma.account.findFirst` can throw | +| `src/app/api/github/repos/route.ts` | Auth + token section outside try/catch; `getGithubAccessTokenForUser` can throw | +| `src/app/api/github/repos/[owner]/[repo]/commits/route.ts` | Same pattern as repos | + +**Routes with adequate error handling:** +- `src/app/api/posts/generate/route.ts` — full outer try/catch ✅ +- `src/app/api/timeline/route.ts` — delegates to `fetchTimelineEntries` which handles errors internally ✅ +- `src/app/api/auth/[...nextauth]/route.ts` — NextAuth handler ✅ + +--- + +#### Audit D — Exposed stack traces + +**1 issue found and fixed:** + +| File | Line | Pattern | Fix applied | +|------|------|---------|-------------| +| `src/app/api/posts/generate/route.ts` | 267 | `detail: String(error)` in the outer catch block | Replaced with `NODE_ENV`-conditional: dev = `String(error)` (original), production = `"See server logs for details."`. The `console.error` that logs the full error is retained. | + +**Additional security note (report only):** +- `src/app/api/debug-session/route.ts` — `return NextResponse.json({ session })` with no authentication guard returns the full NextAuth session object (user `id`, `name`, `email`, `image`, `selectedGithubRepo`, token expiry) to any unauthenticated caller. This route should be removed or protected. + +--- + +#### Audit E — Dead imports and unused files + +**Report only — nothing deleted.** + +| File | Status | Reason | +|------|--------|--------| +| `src/features/github/mapCommitsToTimeline.ts` | ⚠️ Dead | Superseded by `normalizeEvents.ts`. Not imported anywhere. | +| `src/features/timeline/data.ts` | ⚠️ Dead | Fake timeline data for prototype. Not imported anywhere; real data comes from GitHub API. | +| `src/lib/github/index.ts` | ⚠️ Dead barrel | Not imported anywhere. All consumers import directly from `@/lib/github/api`, `@/lib/github/types`, `@/lib/github/normalizeEvents`. | +| `src/lib/github/oauth.ts` | ⚠️ Dead | Only re-exported by the unused barrel. `buildAuthUrl`, `exchangeCodeForToken`, `fetchAuthenticatedUser` are unused; OAuth is handled by NextAuth. | +| `src/components/timeline/TimelineView.tsx` | ⚠️ Dead | Superseded by `ConstellationTimeline`. Not imported outside `src/components/timeline/`. | +| `src/components/timeline/TimelineList.tsx` | ⚠️ Dead | Only used by `TimelineView` (dead). | +| `src/components/timeline/TimelineFilters.tsx` | ⚠️ Dead | Only used by `TimelineView` (dead). | +| `src/components/ui/timeline.tsx` | ⚠️ Dead | Only used by `TimelineView` (dead). | +| `src/features/timeline/useTimelineFilter.ts` | ⚠️ Dead | Only used by `TimelineView` (dead). | + +No broken import paths found — all imports resolve to real files. + +--- + +#### Audit F — TypeScript `any` in critical files + +**Result: NONE FOUND.** + +Checked all 5 files explicitly: +- `src/lib/postGenerator/generatePost.ts` — no `: any` ✅ +- `src/lib/postGenerator/enrichEvent.ts` — no `: any` ✅ +- `src/lib/github/normalizeEvents.ts` — no `: any` ✅ +- `src/app/api/posts/generate/route.ts` — no `: any` ✅ +- `src/app/api/timeline/route.ts` — no `: any` ✅ + +--- + +#### Audit G — Environment variable coverage + +**Result: Full coverage — all code-accessed vars are in `.env`.** + +| Variable | Used in code | In `.env` | +|----------|-------------|----------| +| `ANTHROPIC_API_KEY` | `enrichEvent.ts`, `generatePost.ts` | ✅ | +| `DATABASE_URL` | `[...nextauth]/route.ts` (ENV CHECK) | ✅ | +| `GITHUB_CLIENT_ID` | `lib/auth.ts` | ✅ | +| `GITHUB_CLIENT_SECRET` | `lib/auth.ts` | ✅ | +| `NEXTAUTH_SECRET` | `lib/auth.ts` | ✅ | +| `NEXTAUTH_URL` | `[...nextauth]/route.ts` (ENV CHECK) | ✅ | +| `NODE_ENV` | Multiple files (guard conditions) | Built-in — auto-set by Next.js | +| `DIRECT_URL` | Not accessed via `process.env` in app code — used by Prisma engine only | ✅ | + +**In `.env` but not referenced in app code:** `OPENAI_API_KEY` (present but unused), `REDIS_URL` (present but unused). + +--- + +## Phase 3 — Environment & Auth Verification + +| Step | Description | Status | +|------|-------------|--------| +| 3.1 | Verify Supabase tables exist and schema matches Prisma | 🔲 pending | +| 3.2 | Set all required env vars in production environment (Vercel / host) | 🔲 pending | +| 3.3 | Verify GitHub OAuth end-to-end — sign in, session created in DB | 🔲 pending | +| 3.4 | Verify `selectedGithubRepo` persists correctly across sessions | 🔲 pending | +| 3.5 | Verify timeline loads from real GitHub API (commits, PRs, releases) | 🔲 pending | +| 3.6 | Verify post generation end-to-end (enrich → sanitize → generate) | 🔲 pending | + +--- + +### Agent Log — Phase 3 + +*(no entries yet)* + +--- + +## Phase 4 — Pre-deploy Quality Pass + +| Step | Description | Status | +|------|-------------|--------| +| 4.1 | Run `npx next build` — confirm zero errors | ✅ done | +| 4.2 | Confirm metadata export in `src/app/layout.tsx` (title, description, openGraph) | ✅ done | +| 4.3 | Confirm auth protection on `src/app/(app)/layout.tsx` | ✅ done | +| 4.4 | Add rate limiting (10 s cooldown) to `src/app/api/posts/generate/route.ts` | ✅ done | +| 4.5 | Audit landing page auth CTAs — fix any linking to dead routes | ✅ done | +| 4.6 | Audit codebase for `/calendar` and `/analytics` links — fix nav components | ✅ done | +| 4.7 | Run final `npx next build` — must pass cleanly | ✅ done | + +--- + +### Agent Log — Phase 4 + +**Date:** 2026-05-30 +**Branch:** `launch/phase-4-quality` + +#### Files changed + +| File | Change | +|------|--------| +| `src/app/layout.tsx` | Updated `metadata` export: title changed to plain string `"Devlog — Turn your commits into content"`, description updated to match spec, `openGraph` block added with title, description, and `type: "website"` | +| `src/app/api/posts/generate/route.ts` | Added in-memory rate limiter: `lastRequest` Map at module scope; 10 s per-user cooldown check immediately after session validation, returns HTTP 429 on violation | +| `src/components/ui/shape-landing-hero.tsx` | Hero "Connect GitHub — it's free" CTA: `href="/dashboard"` → `href="/login"` | +| `src/components/layout/Sidebar.tsx` | Disabled nav items (Content, Calendar, Analytics): `href="/content"`, `href="/calendar"`, `href="/analytics"` → `href="#"` to prevent accidental routing if the `disabled` guard is ever removed | + +#### Check-by-check results + +**Check 1 — Initial build** +`npx next build` passed cleanly before any changes. Zero errors, zero type errors. + +**Check 2 — Metadata** +`src/app/layout.tsx` had the correct title string but a different description and no `openGraph` block. Fixed: description updated to spec, `openGraph` block added. + +**Check 3 — Auth protection** +`src/app/(app)/layout.tsx` already calls `getServerSession(authOptions)` and `redirect("/login")` on no session. No change needed. ✅ + +**Check 4 — Rate limiting** +`src/app/api/posts/generate/route.ts` had no rate limiting. Added `lastRequest` Map and 10 s cooldown per user ID returning HTTP 429 on violation. + +**Check 5 — Landing page auth links** +- "Connect GitHub — it's free" (hero CTA) linked to `/dashboard` → fixed to `/login`. +- Navbar: "Sign in" → `/login` ✅, "Get started" → `/login` ✅. +- PricingSection: "Get started free" → `/login` ✅, "Start Pro free for 7 days →" → `/login` ✅. +- Non-auth links reported (not fixed): footer "Privacy" and "Terms" use `href="#"` (placeholder legal pages, not auth CTAs). + +**Check 6 — Disabled features** +`/calendar` and `/analytics` found only in `src/components/layout/Sidebar.tsx` `mainNavItems` data array. The `SidebarNavLink` component renders a `<div>` (not a `<Link>`) for `disabled: true` items, so these routes were never reachable. Hrefs changed to `#` as a defensive fix. + +**Check 7 — Final build** +`npx next build` passes cleanly with zero errors after all changes. + +#### Issues & Blockers + +- `src/app/api/debug-session/route.ts` — unauthenticated endpoint that returns full session data. Flagged in Phase 2 (Audit D). Not removed per Phase 2 instructions ("report only"). Should be removed before production traffic. +- Footer "Privacy" and "Terms" links use `href="#"` — placeholder legal pages not yet created. + +--- + +Phase 4 complete — ready for manual Vercel deployment diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 26244e6..9db6ae4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,19 @@ "": { "name": "devlog", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { + "@next-auth/prisma-adapter": "^1.0.7", + "@prisma/client": "^5.22.0", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "geist": "^1.3.0", "lucide-react": "^0.460.0", "next": "14.2.35", + "next-auth": "^4.24.11", + "next-themes": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.6.1" @@ -25,6 +32,7 @@ "eslint": "^8.57.1", "eslint-config-next": "14.2.35", "postcss": "^8.4.31", + "prisma": "^5.22.0", "tailwindcss": "^3.4.19", "typescript": "^5.9.3" } @@ -42,6 +50,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -276,6 +293,16 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@next-auth/prisma-adapter": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz", + "integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==", + "license": "ISC", + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3", + "next-auth": "^4" + } + }, "node_modules/@next/env": { "version": "14.2.35", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", @@ -484,6 +511,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -495,6 +531,107 @@ "node": ">=14" } }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -557,16 +694,15 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -627,7 +763,6 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -1147,7 +1282,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1580,7 +1714,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1751,6 +1884,18 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -1803,6 +1948,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1835,7 +1989,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2231,7 +2385,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2401,7 +2554,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3811,11 +3963,19 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4147,7 +4307,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "14.2.35", "@swc/helpers": "0.5.5", @@ -4193,6 +4352,48 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", + "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4267,6 +4468,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4400,6 +4607,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4410,6 +4626,42 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4614,7 +4866,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4758,6 +5009,28 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", + "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4768,6 +5041,32 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4816,7 +5115,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4829,7 +5127,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5727,7 +6024,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5897,7 +6193,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6015,6 +6310,15 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6238,6 +6542,12 @@ "dev": true, "license": "ISC" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index b28193f..5eafed3 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,24 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "postinstall": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:push": "prisma db push", + "db:studio": "prisma studio" }, "dependencies": { + "@next-auth/prisma-adapter": "^1.0.7", + "@prisma/client": "^5.22.0", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "geist": "^1.3.0", "lucide-react": "^0.460.0", "next": "14.2.35", + "next-auth": "^4.24.11", + "next-themes": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.6.1" @@ -27,6 +37,7 @@ "eslint": "^8.57.1", "eslint-config-next": "14.2.35", "postcss": "^8.4.31", + "prisma": "^5.22.0", "tailwindcss": "^3.4.19", "typescript": "^5.9.3" } diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..a982c64 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/prisma/migrations/20260530210226_init_postgres/migration.sql b/prisma/migrations/20260530210226_init_postgres/migration.sql new file mode 100644 index 0000000..9aebc69 --- /dev/null +++ b/prisma/migrations/20260530210226_init_postgres/migration.sql @@ -0,0 +1,67 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" TIMESTAMP(3), + "image" TEXT, + "selectedGithubRepo" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/prisma/dev.db b/prisma/prisma/dev.db new file mode 100644 index 0000000..7c5e522 Binary files /dev/null and b/prisma/prisma/dev.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..b7be3d2 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,57 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") +} + +// NextAuth.js models — https://next-auth.js.org/adapters/prisma +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + /// Full name `owner/repo` for timeline + API; scoped per user in DB. + selectedGithubRepo String? + accounts Account[] + sessions Session[] +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} diff --git a/public/devlog-logo.png b/public/devlog-logo.png new file mode 100644 index 0000000..6a099ef Binary files /dev/null and b/public/devlog-logo.png differ diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..75508bc --- /dev/null +++ b/src/README.md @@ -0,0 +1,25 @@ +# Source Layout + +This folder follows a feature-aware single-app structure. + +## Folder rules + +- `app/` + - Route segments, layouts, and Next.js route handlers only. +- `components/` + - Reusable UI pieces shared across multiple features. +- `features/` + - Product domains. Put feature-specific UI, hooks, and orchestration here before promoting truly reusable pieces to `components/`. +- `lib/` + - Shared helpers and infrastructure clients such as AI SDK wrappers, GitHub helpers, auth utilities, DB clients, and generic utilities. +- `server/` + - Server-only business logic, repositories, jobs, and schemas. Avoid putting complex business logic directly in route handlers. +- `styles/` + - Global styling and theme-level files. +- `types/` + - Shared app-level types that are not better owned by a specific feature. + +## Recommended ownership model + +- If only one domain uses it, keep it inside `features/<domain>`. +- If multiple domains use it, move it to `components/`, `lib/`, `server/`, or `types/` depending on what it is. diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index ad5bb00..a0e286e 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,54 +1,222 @@ import { Topbar } from "@/components/layout/Topbar"; +import { + GitCommit, + FileText, + Share2, + Flame, + GitPullRequest, + Tag, + ArrowRight, + Plus, + Github, + CalendarDays, +} from "lucide-react"; -const STATS = [ - { label: "Timeline entries", value: "42", note: "Across commits, PRs, and releases" }, - { label: "Draft posts", value: "9", note: "Ready to review and refine" }, - { label: "Published", value: "14", note: "Shared across social platforms" }, -]; - -const RECENT_ACTIVITY = [ - "Refined the timeline card layout and metadata hierarchy.", - "Restored the root Tailwind + Next.js scaffold so the app can boot cleanly.", - "Documented architecture decisions and branch coordination rules.", -]; +export const metadata = { + title: "Dashboard", +}; export default function DashboardPage() { return ( - <div className="flex min-h-screen flex-col"> + <div className="flex flex-col min-h-screen"> <Topbar title="Dashboard" - description="High-level view of your build journey and publishing workflow" + description="Overview of your build activity and generated content" /> - <main className="flex-1 p-6"> - <div className="mx-auto max-w-6xl space-y-6"> - <section className="grid gap-4 md:grid-cols-3"> - {STATS.map((stat) => ( - <div - key={stat.label} - className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-5" - > - <p className="text-sm text-zinc-500">{stat.label}</p> - <p className="mt-3 text-3xl font-semibold text-zinc-100">{stat.value}</p> - <p className="mt-2 text-sm text-zinc-400">{stat.note}</p> + + <main className="flex-1 p-6 space-y-6 animate-fade-in"> + <div> + <h2 className="text-xl font-semibold text-zinc-100 tracking-tight"> + Good morning. + </h2> + <p className="mt-0.5 text-sm text-zinc-500"> + Here's what's happening with your repos today. + </p> + </div> + + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> + {stats.map((stat) => ( + <div + key={stat.label} + className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 transition-colors hover:border-zinc-700" + > + <div className="mb-3 flex items-center justify-between"> + <p className="text-xs font-medium text-zinc-500">{stat.label}</p> + <div className="flex h-7 w-7 items-center justify-center rounded-md bg-zinc-800"> + <stat.icon className="h-3.5 w-3.5 text-zinc-400" /> + </div> </div> - ))} - </section> - - <section className="rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6"> - <p className="text-sm font-medium text-zinc-200">Recent activity</p> - <div className="mt-4 space-y-3"> - {RECENT_ACTIVITY.map((item) => ( - <div - key={item} - className="rounded-xl border border-zinc-800 bg-zinc-950/70 px-4 py-3 text-sm text-zinc-400" - > - {item} + <p className="font-mono text-2xl font-bold tracking-tight text-zinc-100"> + {stat.value} + </p> + <p className="mt-1 text-xs text-zinc-600">{stat.change}</p> + </div> + ))} + </div> + + <div className="grid gap-5 lg:grid-cols-3"> + <div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/40 lg:col-span-2"> + <div className="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5"> + <h3 className="text-sm font-medium text-zinc-100">Recent Activity</h3> + <a + href="/timeline" + className="flex items-center gap-1 text-xs text-zinc-500 transition-colors hover:text-violet-400" + > + View timeline + <ArrowRight className="h-3 w-3" /> + </a> + </div> + + <div className="divide-y divide-zinc-800/60"> + {recentActivity.map((item) => ( + <div key={item.id} className="flex items-start gap-3 px-5 py-3.5"> + <div + className={`mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md ${item.iconBg}`} + > + <item.icon className={`h-3.5 w-3.5 ${item.iconColor}`} /> + </div> + <div className="min-w-0 flex-1"> + <p className="text-sm leading-snug text-zinc-200">{item.title}</p> + <div className="mt-0.5 flex items-center gap-2"> + <span className="font-mono text-[11px] text-zinc-500"> + {item.repo} + </span> + <span className="text-zinc-700">·</span> + <span className="text-[11px] text-zinc-500">{item.time}</span> + </div> + </div> + {item.hasPost && ( + <span className="flex-shrink-0 rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] text-emerald-400"> + post ready + </span> + )} </div> ))} </div> - </section> + </div> + + <div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/40"> + <div className="border-b border-zinc-800 px-5 py-3.5"> + <h3 className="text-sm font-medium text-zinc-100">Quick Actions</h3> + </div> + + <div className="space-y-2 p-4"> + {quickActions.map((action) => ( + <button + key={action.label} + disabled={action.disabled} + className="w-full rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2.5 text-left text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-35" + > + <div className="flex items-center gap-3"> + <action.icon className="h-4 w-4 flex-shrink-0 text-zinc-500" /> + <span className="flex-1 text-xs">{action.label}</span> + {action.disabled && ( + <span className="text-[9px] font-medium uppercase tracking-wider text-zinc-700"> + soon + </span> + )} + </div> + </button> + ))} + </div> + + <div className="px-4 pb-4"> + <div className="rounded-lg border border-dashed border-zinc-800 bg-zinc-900/30 p-4 text-center"> + <Github className="mx-auto mb-2 h-6 w-6 text-zinc-700" /> + <p className="mb-1 text-xs text-zinc-500">No repos connected</p> + <p className="mb-3 text-[11px] leading-relaxed text-zinc-600"> + Connect a GitHub repo to start tracking activity. + </p> + <a + href="/settings" + className="inline-flex items-center gap-1.5 text-xs text-violet-400 transition-colors hover:text-violet-300" + > + <Plus className="h-3 w-3" /> + Connect a repository + </a> + </div> + </div> + </div> </div> </main> </div> ); } + +const stats = [ + { + label: "Commits this week", + value: "0", + change: "Connect a repo to start tracking", + icon: GitCommit, + }, + { + label: "Posts generated", + value: "0", + change: "Waiting for GitHub activity", + icon: FileText, + }, + { + label: "Platforms connected", + value: "0", + change: "Add publishing targets", + icon: Share2, + }, + { + label: "Build streak", + value: "—", + change: "Start shipping to track", + icon: Flame, + }, +]; + +const recentActivity = [ + { + id: 1, + title: "Set up base Next.js application structure", + repo: "devlog", + time: "just now", + icon: GitCommit, + iconBg: "bg-amber-500/10", + iconColor: "text-amber-400", + hasPost: false, + }, + { + id: 2, + title: "feature/dev-timeline-ui — opened", + repo: "devlog", + time: "2 days ago", + icon: GitPullRequest, + iconBg: "bg-violet-500/10", + iconColor: "text-violet-400", + hasPost: false, + }, + { + id: 3, + title: "v0.1.0 · Project initialized", + repo: "devlog", + time: "3 days ago", + icon: Tag, + iconBg: "bg-emerald-500/10", + iconColor: "text-emerald-400", + hasPost: false, + }, +]; + +const quickActions = [ + { + label: "Generate post from latest commit", + icon: FileText, + disabled: true, + }, + { + label: "Connect a GitHub repository", + icon: Github, + disabled: false, + }, + { + label: "Schedule a post", + icon: CalendarDays, + disabled: true, + }, +]; diff --git a/src/app/(app)/generate/page.tsx b/src/app/(app)/generate/page.tsx new file mode 100644 index 0000000..bd4f94f --- /dev/null +++ b/src/app/(app)/generate/page.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Loader2, Sparkles } from "lucide-react"; +import { Topbar } from "@/components/layout/Topbar"; +import { EventSelector } from "@/components/postGenerator/EventSelector"; +import { OptionsPanel } from "@/components/postGenerator/OptionsPanel"; +import { ResultsPanel } from "@/components/postGenerator/ResultsPanel"; +import GeneratingOverlay from "@/components/postGenerator/loading/GeneratingOverlay"; +import { cn } from "@/lib/utils/cn"; +import type { TimelineEntry } from "@/features/timeline/types"; + +// ── Types ────────────────────────────────────────────────────────────────── + +type Platform = "x" | "linkedin" | "reddit"; +type Tone = "casual" | "professional" | "feedback-seeking" | "educational"; +type PrivacyLevel = "high" | "medium" | "low"; + +type GeneratedPost = { + platform: Platform; + content: string; + characterCount: number; +}; + +type GenerateResponse = + | { posts: GeneratedPost[]; failed?: Platform[] } + | { error: string; detail?: string }; + +// ── Loading skeleton ─────────────────────────────────────────────────────── + +function EventSkeleton() { + return ( + <div className="space-y-2 animate-pulse"> + {[1, 2, 3].map((i) => ( + <div + key={i} + className="h-20 rounded-xl border border-zinc-800 bg-zinc-900/40" + /> + ))} + </div> + ); +} + +// ── Page ─────────────────────────────────────────────────────────────────── + +export default function GeneratePage() { + // ── Data ── + const [entries, setEntries] = useState<TimelineEntry[]>([]); + const [loadingEntries, setLoadingEntries] = useState(true); + const [loadError, setLoadError] = useState<string | null>(null); + + // ── Selections ── + const [selectedIds, setSelectedIds] = useState<string[]>([]); + const [platforms, setPlatforms] = useState<Platform[]>(["linkedin"]); + const [tone, setTone] = useState<Tone>("casual"); + const [privacyLevel, setPrivacyLevel] = useState<PrivacyLevel>("medium"); + + // ── Generation ── + const [posts, setPosts] = useState<GeneratedPost[]>([]); + const [generating, setGenerating] = useState(false); + const [regenerating, setRegenerating] = useState<Platform[]>([]); + const [error, setError] = useState<string | null>(null); + const [partialFailures, setPartialFailures] = useState<Platform[]>([]); + + const resultsRef = useRef<HTMLDivElement>(null); + + // ── Load timeline ────────────────────────────────────────────────────── + + useEffect(() => { + async function load() { + try { + const res = await fetch("/api/timeline"); + if (!res.ok) { + setLoadError( + "Could not load events. Make sure you have a GitHub repo selected in Settings.", + ); + return; + } + const data: unknown = await res.json(); + setEntries(Array.isArray(data) ? (data as TimelineEntry[]) : []); + } catch { + setLoadError("Network error loading events."); + } finally { + setLoadingEntries(false); + } + } + load(); + }, []); + + // ── Helpers ──────────────────────────────────────────────────────────── + + function buildRequestBody(overridePlatforms: Platform[]) { + return { + timelineEntryIds: selectedIds, + platforms: overridePlatforms, + tone, + privacyLevel, + }; + } + + function mergeIntoPost( + prev: GeneratedPost[], + incoming: GeneratedPost[], + ): GeneratedPost[] { + const map = new Map(prev.map((p) => [p.platform, p])); + for (const p of incoming) map.set(p.platform, p); + return Array.from(map.values()); + } + + async function callGenerateApi( + body: object, + ): Promise<GenerateResponse | null> { + const res = await fetch("/api/posts/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + const data = (await res.json().catch(() => null)) as GenerateResponse | null; + + if (!res.ok && res.status !== 207) { + return data; + } + return data; + } + + // ── Generate all ─────────────────────────────────────────────────────── + + const canGenerate = selectedIds.length > 0 && platforms.length > 0; + + async function handleGenerate() { + if (!canGenerate || generating) return; + setGenerating(true); + setError(null); + setPartialFailures([]); + + try { + const data = await callGenerateApi(buildRequestBody(platforms)); + + if (!data || "error" in data) { + setError( + (data as { error?: string } | null)?.error ?? + "Generation failed. Please try again.", + ); + return; + } + + setPosts(data.posts); + setPartialFailures(data.failed ?? []); + } catch { + setError("Network error. Please try again."); + } finally { + setGenerating(false); + } + } + + // ── Regenerate one platform ──────────────────────────────────────────── + + async function handleRegenerate(platform: Platform) { + if (regenerating.includes(platform)) return; + setRegenerating((prev) => [...prev, platform]); + setError(null); + + try { + const data = await callGenerateApi(buildRequestBody([platform])); + + if (!data || "error" in data) { + setError( + (data as { error?: string } | null)?.error ?? + `Failed to regenerate ${platform} post.`, + ); + return; + } + + setPosts((prev) => mergeIntoPost(prev, data.posts)); + // Remove platform from failures if it now succeeded + if (data.posts.some((p) => p.platform === platform)) { + setPartialFailures((prev) => prev.filter((f) => f !== platform)); + } + } catch { + setError(`Network error regenerating ${platform} post.`); + } finally { + setRegenerating((prev) => prev.filter((p) => p !== platform)); + } + } + + // ── Render ───────────────────────────────────────────────────────────── + + return ( + <div className="flex min-h-screen flex-col"> + <GeneratingOverlay + isVisible={generating} + onComplete={() => { + resultsRef.current?.scrollIntoView({ behavior: "smooth" }); + }} + /> + + <Topbar + title="Generate Posts" + description="Turn your recent dev events into shareable content" + /> + + <main className="flex-1 animate-fade-in p-6"> + <div className="mx-auto max-w-6xl"> + + {/* Two-column grid on large screens */} + <div className="grid gap-8 lg:grid-cols-[1fr,1.25fr]"> + + {/* ── Left: event selector ── */} + <section className="min-w-0"> + <h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500"> + 1 — Select events + </h2> + + {loadingEntries ? ( + <EventSkeleton /> + ) : loadError ? ( + <p className="text-sm text-amber-400">{loadError}</p> + ) : entries.length === 0 ? ( + <p className="text-sm text-zinc-500"> + No events yet.{" "} + <a + href="/settings" + className="text-violet-400 transition-colors hover:text-violet-300" + > + Connect a GitHub repo in Settings + </a>{" "} + to get started. + </p> + ) : ( + <EventSelector + entries={entries} + selectedIds={selectedIds} + onChange={setSelectedIds} + /> + )} + </section> + + {/* ── Right: options + action + results ── */} + <section className="min-w-0 space-y-8"> + + {/* Options */} + <div> + <h2 className="mb-4 text-xs font-semibold uppercase tracking-widest text-zinc-500"> + 2 — Options + </h2> + <OptionsPanel + selectedPlatforms={platforms} + tone={tone} + privacyLevel={privacyLevel} + onPlatformsChange={setPlatforms} + onToneChange={(t) => setTone(t as Tone)} + onPrivacyChange={(l) => setPrivacyLevel(l as PrivacyLevel)} + /> + </div> + + {/* Generate button */} + <div className="space-y-3"> + <button + type="button" + onClick={handleGenerate} + disabled={!canGenerate || generating} + className={cn( + "flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-semibold transition-colors sm:w-auto", + canGenerate && !generating + ? "bg-violet-600 text-white hover:bg-violet-500" + : "cursor-not-allowed bg-zinc-800 text-zinc-600", + )} + > + {generating ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Generating… + </> + ) : ( + <> + <Sparkles className="h-4 w-4" /> + Generate Posts + </> + )} + </button> + + {!canGenerate && !generating && ( + <p className="text-[11px] text-zinc-600"> + {selectedIds.length === 0 + ? "Select at least one event to continue." + : "Select at least one platform to continue."} + </p> + )} + + {error && ( + <p className="text-sm text-red-400">{error}</p> + )} + </div> + + {/* Results */} + {(posts.length > 0 || partialFailures.length > 0) && ( + <div ref={resultsRef} className="space-y-4"> + <h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500"> + 3 — Generated posts + </h2> + + {partialFailures.length > 0 && ( + <p className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs text-amber-400"> + Generation failed for:{" "} + <span className="font-medium"> + {partialFailures.join(", ")} + </span> + . Use Regenerate on any card below, or try again. + </p> + )} + + <ResultsPanel + posts={posts} + regenerating={regenerating} + onRegenerate={handleRegenerate} + /> + </div> + )} + </section> + </div> + </div> + </main> + </div> + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fb33aa2..e944073 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,6 +1,18 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; import { Sidebar } from "@/components/layout/Sidebar"; +import { authOptions } from "@/lib/auth"; + +export default async function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + if (!session) { + redirect("/login"); + } -export default function AppLayout({ children }: { children: React.ReactNode }) { return ( <div className="flex min-h-screen bg-zinc-950"> <Sidebar /> diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 60f9bac..a1697d6 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,42 +1,250 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; import { Topbar } from "@/components/layout/Topbar"; +import { GitHubSettingsSection } from "@/components/github"; +import { + Github, + Bell, + Lock, + CreditCard, + User, + ChevronRight, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { authOptions } from "@/lib/auth"; +import { buildGithubConnectionStatus } from "@/server/github/buildGithubConnectionStatus"; -const SECTIONS = [ - { - title: "Privacy controls", - description: "Choose how much implementation detail Devlog can surface in summaries and drafts.", - }, - { - title: "Platforms & tone", - description: "Control voice, formatting, and platform-specific framing for X, LinkedIn, and Reddit.", - }, - { - title: "Repository preferences", - description: "Manage connected repos, active branches, and event sources for your timeline.", - }, -]; +export const metadata = { + title: "Settings", +}; + +export default async function SettingsPage() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + redirect("/login"); + } + + const githubStatus = await buildGithubConnectionStatus(session); + + const displayName = session.user.name ?? session.user.email ?? "User"; + const displayEmail = session.user.email ?? ""; + const avatarLetter = displayName.charAt(0).toUpperCase(); -export default function SettingsPage() { return ( - <div className="flex min-h-screen flex-col"> + <div className="flex flex-col min-h-screen"> <Topbar title="Settings" - description="Configure privacy, tone, and repository behavior" + description="Manage your account, integrations, and preferences" /> - <main className="flex-1 p-6"> - <div className="mx-auto max-w-4xl space-y-4"> - {SECTIONS.map((section) => ( - <section - key={section.title} - className="rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5" - > - <h2 className="text-sm font-medium text-zinc-100">{section.title}</h2> - <p className="mt-2 text-sm leading-relaxed text-zinc-400"> - {section.description} - </p> - </section> - ))} + + <main className="flex-1 animate-fade-in p-6"> + <div className="max-w-2xl space-y-5"> + <SettingsSection + icon={User} + title="Profile" + description="Your account information" + > + <div className="flex items-center gap-4"> + <div className="flex h-14 w-14 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-violet-500/20 text-lg font-semibold text-violet-300"> + {session.user.image ? ( + // eslint-disable-next-line @next/next/no-img-element + <img + src={session.user.image} + alt="" + className="h-full w-full object-cover" + /> + ) : ( + avatarLetter + )} + </div> + <div className="min-w-0 flex-1"> + <p className="text-sm font-medium text-zinc-100">{displayName}</p> + {displayEmail && ( + <p className="text-xs text-zinc-500">{displayEmail}</p> + )} + <p className="mt-1 text-[11px] text-zinc-600"> + Signed in with GitHub. Each Devlog user has their own GitHub + connection and repo choice. + </p> + </div> + </div> + </SettingsSection> + + <SettingsSection + icon={Github} + title="GitHub Integration" + description="Connected repositories and activity tracking" + > + <GitHubSettingsSection initialStatus={githubStatus} /> + </SettingsSection> + + <SettingsSection + icon={Lock} + title="Privacy Controls" + description="Control how much detail appears in generated content" + > + <div className="space-y-2"> + {privacyLevels.map((level) => ( + <label + key={level.name} + className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3.5 transition-colors ${ + level.selected + ? "border-violet-500/40 bg-violet-500/5" + : "border-zinc-800 hover:border-zinc-700" + }`} + > + <div + className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border transition-colors ${ + level.selected + ? "border-violet-500 bg-violet-500" + : "border-zinc-600" + }`} + > + {level.selected && ( + <div className="h-1.5 w-1.5 rounded-full bg-white" /> + )} + </div> + <div> + <p className="text-sm font-medium text-zinc-200">{level.name}</p> + <p className="mt-0.5 text-xs leading-relaxed text-zinc-500"> + {level.description} + </p> + </div> + </label> + ))} + </div> + </SettingsSection> + + <SettingsSection + icon={Bell} + title="Notifications" + description="When and how Devlog alerts you" + > + <div className="space-y-4"> + {notifications.map((item) => ( + <div key={item.label} className="flex items-center justify-between gap-4"> + <div className="min-w-0"> + <p className="text-sm text-zinc-200">{item.label}</p> + <p className="text-xs text-zinc-500">{item.description}</p> + </div> + <button + className={`relative h-5 w-9 flex-shrink-0 rounded-full transition-colors ${ + item.enabled ? "bg-violet-500" : "bg-zinc-700" + }`} + > + <div + className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${ + item.enabled ? "translate-x-4" : "translate-x-0.5" + }`} + /> + </button> + </div> + ))} + </div> + </SettingsSection> + + <SettingsSection + icon={CreditCard} + title="Plan" + description="Your current subscription" + > + <div className="flex items-center justify-between rounded-lg border border-zinc-800 p-4"> + <div> + <div className="mb-1 flex items-center gap-2"> + <p className="text-sm font-medium text-zinc-100">Free</p> + <span className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500"> + Current + </span> + </div> + <p className="text-xs text-zinc-500"> + 1 repo · 10 posts/month · No scheduling + </p> + </div> + <button className="flex items-center gap-1 text-xs text-violet-400 transition-colors hover:text-violet-300"> + Upgrade to Pro + <ChevronRight className="h-3 w-3" /> + </button> + </div> + + <div className="mt-3 rounded-lg border border-violet-500/20 bg-violet-500/5 p-4"> + <p className="mb-1 text-sm font-medium text-violet-300">Devlog Pro</p> + <ul className="mb-3 space-y-1 text-xs text-zinc-400"> + <li>Unlimited generation</li> + <li>Scheduling + content calendar</li> + <li>Tone presets + platform templates</li> + <li>Multi-platform publishing</li> + </ul> + <button className="rounded-md bg-violet-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-violet-400"> + Upgrade — coming soon + </button> + </div> + </SettingsSection> </div> </main> </div> ); } + +function SettingsSection({ + icon: Icon, + title, + description, + children, +}: { + icon: LucideIcon; + title: string; + description: string; + children: React.ReactNode; +}) { + return ( + <div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/40"> + <div className="flex items-center gap-3 border-b border-zinc-800 px-5 py-4"> + <Icon className="h-4 w-4 flex-shrink-0 text-zinc-500" /> + <div> + <h3 className="text-sm font-medium text-zinc-100">{title}</h3> + <p className="text-xs text-zinc-500">{description}</p> + </div> + </div> + <div className="p-5">{children}</div> + </div> + ); +} + +const privacyLevels = [ + { + name: "High Privacy", + description: + "Commit messages and high-level summaries only. No code, file paths, or implementation details in generated content.", + selected: true, + }, + { + name: "Medium Privacy", + description: + "Describes behavior and impact without exposing sensitive internals, algorithm details, or file paths.", + selected: false, + }, + { + name: "Low Privacy", + description: + "Best for open-source. Allows specific feature mentions, links, and more technical detail in generated content.", + selected: false, + }, +]; + +const notifications = [ + { + label: "Post drafts ready", + description: "When Devlog generates a new post from your GitHub activity", + enabled: true, + }, + { + label: "Weekly digest", + description: "A summary of your build activity every Monday", + enabled: false, + }, + { + label: "Publishing confirmations", + description: "When a post is successfully published to a platform", + enabled: true, + }, +]; diff --git a/src/app/(app)/timeline/page.tsx b/src/app/(app)/timeline/page.tsx index 5fb7fb1..0fd4a3a 100644 --- a/src/app/(app)/timeline/page.tsx +++ b/src/app/(app)/timeline/page.tsx @@ -1,12 +1,39 @@ +import { getServerSession } from "next-auth"; +import { Github } from "lucide-react"; import { Topbar } from "@/components/layout/Topbar"; -import { TimelineView } from "@/components/timeline"; -import { TIMELINE_ENTRIES } from "@/features/timeline/data"; +import ConstellationTimeline from "@/components/timeline/ConstellationTimeline"; +import { authOptions } from "@/lib/auth"; +import { fetchTimelineEntries } from "@/server/timeline/fetchTimelineEntries"; export const metadata = { title: "Timeline", }; -export default function TimelinePage() { +export default async function TimelinePage() { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return ( + <div className="flex flex-col min-h-screen"> + <Topbar + title="Dev Timeline" + description="Your complete build journey, commit by commit" + /> + <main className="flex-1 p-6"> + <div className="mt-24 text-center"> + <p className="text-sm text-zinc-500">Sign in to view your timeline.</p> + </div> + </main> + </div> + ); + } + + const result = await fetchTimelineEntries(session.user.id); + + const entries = result.ok ? result.entries : []; + const fetchError = !result.ok && result.reason === "fetch_error"; + const repoName = session.user.selectedGithubRepo ?? null; + return ( <div className="flex flex-col min-h-screen"> <Topbar @@ -14,7 +41,28 @@ export default function TimelinePage() { description="Your complete build journey, commit by commit" /> <main className="flex-1 p-6 animate-fade-in"> - <TimelineView entries={TIMELINE_ENTRIES} /> + {repoName && entries.length > 0 && ( + <div className="mb-6 flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3"> + <Github className="h-4 w-4 flex-shrink-0 text-emerald-400" /> + <p className="text-sm text-zinc-300"> + Showing events from{" "} + <span className="font-mono text-emerald-400">{repoName}</span> + </p> + <span className="ml-auto text-xs text-zinc-600"> + {entries.length} event{entries.length !== 1 ? "s" : ""} + </span> + </div> + )} + + {fetchError ? ( + <div className="mt-24 text-center"> + <p className="text-sm text-zinc-500"> + Could not load events. Check your GitHub connection in Settings. + </p> + </div> + ) : ( + <ConstellationTimeline entries={entries} /> + )} </main> </div> ); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..bb7f05b --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,16 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +if (process.env.NODE_ENV !== "production") { + console.log("[ENV CHECK]", { + hasClientId: !!process.env.GITHUB_CLIENT_ID, + hasClientSecret: !!process.env.GITHUB_CLIENT_SECRET, + hasSecret: !!process.env.NEXTAUTH_SECRET, + nextAuthUrl: process.env.NEXTAUTH_URL, + hasDatabaseUrl: !!process.env.DATABASE_URL, + }); +} + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/api/debug-session/route.ts b/src/app/api/debug-session/route.ts new file mode 100644 index 0000000..c6d3ca3 --- /dev/null +++ b/src/app/api/debug-session/route.ts @@ -0,0 +1,8 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function GET() { + const session = await getServerSession(authOptions); + return NextResponse.json({ session }); +} diff --git a/src/app/api/github/disconnect/route.ts b/src/app/api/github/disconnect/route.ts new file mode 100644 index 0000000..9ace915 --- /dev/null +++ b/src/app/api/github/disconnect/route.ts @@ -0,0 +1,19 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +/** Clears the tracked repository only. GitHub sign-in remains; use Sign out to leave the app. */ +export async function POST() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await prisma.user.update({ + where: { id: session.user.id }, + data: { selectedGithubRepo: null }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/github/repos/[owner]/[repo]/commits/route.ts b/src/app/api/github/repos/[owner]/[repo]/commits/route.ts new file mode 100644 index 0000000..cf8f14a --- /dev/null +++ b/src/app/api/github/repos/[owner]/[repo]/commits/route.ts @@ -0,0 +1,33 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { fetchRepoCommits } from "@/lib/github/api"; +import { getGithubAccessTokenForUser } from "@/server/github/getGithubAccessToken"; + +export async function GET( + _request: Request, + { params }: { params: { owner: string; repo: string } }, +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const token = await getGithubAccessTokenForUser(session.user.id); + if (!token) { + return NextResponse.json( + { error: "No GitHub token on file. Sign in again with GitHub." }, + { status: 401 }, + ); + } + + try { + const commits = await fetchRepoCommits(token, params.owner, params.repo, { + perPage: 20, + }); + return NextResponse.json(commits); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch commits"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/github/repos/route.ts b/src/app/api/github/repos/route.ts new file mode 100644 index 0000000..8960693 --- /dev/null +++ b/src/app/api/github/repos/route.ts @@ -0,0 +1,28 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { fetchUserRepos } from "@/lib/github/api"; +import { getGithubAccessTokenForUser } from "@/server/github/getGithubAccessToken"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const token = await getGithubAccessTokenForUser(session.user.id); + if (!token) { + return NextResponse.json( + { error: "No GitHub token on file. Sign in again with GitHub." }, + { status: 401 }, + ); + } + + try { + const repos = await fetchUserRepos(token, { perPage: 50 }); + return NextResponse.json(repos); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch repos"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/github/select-repo/route.ts b/src/app/api/github/select-repo/route.ts new file mode 100644 index 0000000..a57cd05 --- /dev/null +++ b/src/app/api/github/select-repo/route.ts @@ -0,0 +1,24 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function POST(request: Request) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { repo } = body as { repo: string | null }; + + const value = + typeof repo === "string" && repo.trim().length > 0 ? repo.trim() : null; + + await prisma.user.update({ + where: { id: session.user.id }, + data: { selectedGithubRepo: value }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/github/status/route.ts b/src/app/api/github/status/route.ts new file mode 100644 index 0000000..7a0bad7 --- /dev/null +++ b/src/app/api/github/status/route.ts @@ -0,0 +1,37 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import type { GitHubConnectionStatus } from "@/lib/github/types"; +import { prisma } from "@/lib/prisma"; +import { getGithubAccessTokenForUser } from "@/server/github/getGithubAccessToken"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + const status: GitHubConnectionStatus = { + connected: false, + user: null, + selectedRepo: null, + }; + return NextResponse.json(status); + } + + const token = await getGithubAccessTokenForUser(session.user.id); + const account = await prisma.account.findFirst({ + where: { userId: session.user.id, provider: "github" }, + select: { providerAccountId: true }, + }); + + const status: GitHubConnectionStatus = { + connected: !!token, + user: account + ? { + login: account.providerAccountId, + avatarUrl: session.user.image ?? "", + } + : null, + selectedRepo: session.user.selectedGithubRepo, + }; + + return NextResponse.json(status); +} diff --git a/src/app/api/posts/generate/route.ts b/src/app/api/posts/generate/route.ts new file mode 100644 index 0000000..8a9c85c --- /dev/null +++ b/src/app/api/posts/generate/route.ts @@ -0,0 +1,289 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; + +const lastRequest = new Map<string, number>(); +import { prisma } from "@/lib/prisma"; +import type { TimelineEntry } from "@/features/timeline/types"; +import { + fetchRepoCommits, + fetchRepoPullRequests, + fetchRepoReleases, +} from "@/lib/github/api"; +import { + normalizeCommit, + normalizePullRequest, + normalizeRelease, +} from "@/lib/github/normalizeEvents"; +import { getGithubAccessTokenForUser } from "@/server/github/getGithubAccessToken"; +import { + enrichEvent, + type EnrichedEvent, +} from "@/lib/postGenerator/enrichEvent"; +import { sanitizeEvent } from "@/lib/postGenerator/sanitizeEvent"; +import { + generatePost, + type GeneratedPost, + type Platform, +} from "@/lib/postGenerator/generatePost"; +import { withRetry } from "@/lib/postGenerator/withRetry"; + +const PLATFORMS = ["x", "linkedin", "reddit"] as const; +const TONES = [ + "casual", + "professional", + "feedback-seeking", + "educational", +] as const; +const PRIVACY = ["high", "medium", "low"] as const; + +type Tone = (typeof TONES)[number]; +type Privacy = (typeof PRIVACY)[number]; + +interface GenerateBody { + timelineEntryIds: string[]; + platforms: Platform[]; + tone: Tone; + privacyLevel: Privacy; +} + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every((x) => typeof x === "string"); +} + +function parseBody(raw: unknown): GenerateBody | null { + if (!raw || typeof raw !== "object") return null; + const b = raw as Record<string, unknown>; + + if (!isStringArray(b.timelineEntryIds) || b.timelineEntryIds.length === 0) { + return null; + } + + if ( + !Array.isArray(b.platforms) || + b.platforms.length === 0 || + !b.platforms.every( + (p): p is Platform => + typeof p === "string" && (PLATFORMS as readonly string[]).includes(p), + ) + ) { + return null; + } + + if ( + typeof b.tone !== "string" || + !(TONES as readonly string[]).includes(b.tone) + ) { + return null; + } + if ( + typeof b.privacyLevel !== "string" || + !(PRIVACY as readonly string[]).includes(b.privacyLevel) + ) { + return null; + } + + return { + timelineEntryIds: b.timelineEntryIds, + platforms: b.platforms as Platform[], + tone: b.tone as Tone, + privacyLevel: b.privacyLevel as Privacy, + }; +} + +const DIFFICULTY_RANK: Record<EnrichedEvent["difficulty"], number> = { + trivial: 0, + moderate: 1, + significant: 2, +}; + +/** + * Pick the single "anchor" event to drive generation when the user has + * selected multiple timeline entries. + * + * Why one anchor instead of merging all selected events into the prompt: + * - Concatenating multiple events into one post tends to produce muddled, + * multi-topic drafts that read like a status report, not a hook. + * - Each platform template is tuned to a single thesis (X = one hook, + * LinkedIn = one story arc, Reddit = one title + body). + * + * Selection rule: + * 1. Highest difficulty wins (significant > moderate > trivial). + * 2. On a tie, the most recent entry by `dateIso` wins. + * + * A future task may swap this for a multi-event narrative generator. + */ +function pickAnchorEvent(events: EnrichedEvent[]): EnrichedEvent { + return [...events].sort((a, b) => { + const rankDelta = + DIFFICULTY_RANK[b.difficulty] - DIFFICULTY_RANK[a.difficulty]; + if (rankDelta !== 0) return rankDelta; + return b.originalEntry.dateIso.localeCompare(a.originalEntry.dateIso); + })[0]; +} + +export async function POST(request: Request) { + try { + console.log("[generate] step 1 — request received"); + + const session = await getServerSession(authOptions); + console.log("[generate] step 3 — session:", !!session); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const now = Date.now(); + const last = lastRequest.get(session.user.id) ?? 0; + if (now - last < 10000) { + return NextResponse.json( + { error: "Please wait a moment before generating again." }, + { status: 429 }, + ); + } + lastRequest.set(session.user.id, now); + + let raw: unknown; + try { + raw = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + const body = parseBody(raw); + if (!body) { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + console.log("[generate] step 2 — body:", { + timelineEntryIds: body.timelineEntryIds, + platforms: body.platforms, + tone: body.tone, + privacyLevel: body.privacyLevel, + }); + + // Load timeline directly — no internal HTTP call (loopback self-fetch is + // unreliable in Next.js dev mode and causes ECONNREFUSED). This mirrors + // the logic in GET /api/timeline. + console.log("[generate] step 4 — timeline fetch starting"); + + const token = await getGithubAccessTokenForUser(session.user.id); + if (!token) { + return NextResponse.json( + { error: "No GitHub token on file. Sign in again with GitHub." }, + { status: 401 }, + ); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { selectedGithubRepo: true }, + }); + const selected = user?.selectedGithubRepo ?? null; + if (!selected) { + return NextResponse.json( + { error: "No matching timeline entries found" }, + { status: 400 }, + ); + } + + const [owner, repo] = selected.split("/", 2); + if (!owner || !repo) { + return NextResponse.json( + { error: "No matching timeline entries found" }, + { status: 400 }, + ); + } + + const [commits, pulls, releases] = await Promise.all([ + fetchRepoCommits(token, owner, repo), + fetchRepoPullRequests(token, owner, repo), + fetchRepoReleases(token, owner, repo), + ]); + + const repoName = `${owner}/${repo}`; + const allEntries: TimelineEntry[] = [ + ...commits.map((c) => normalizeCommit(c, repoName)), + ...pulls.map((p) => normalizePullRequest(p, repoName)), + ...releases.map((r) => normalizeRelease(r, repoName)), + ]; + + const wanted = new Set(body.timelineEntryIds); + const selectedEntries = allEntries.filter((e) => wanted.has(e.id)); + + console.log("[generate] step 5 — timeline entries found:", selectedEntries.length); + + if (selectedEntries.length === 0) { + return NextResponse.json( + { error: "No matching timeline entries found" }, + { status: 400 }, + ); + } + + console.log("[generate] step 7 — enrichment starting"); + const enriched = await Promise.all( + selectedEntries.map((entry) => enrichEvent(entry)), + ); + + console.log("[generate] step 8 — sanitization starting"); + const sanitized = enriched.map((e) => + sanitizeEvent(e, body.privacyLevel), + ); + + const anchor = pickAnchorEvent(sanitized); + console.log("[generate] step 6 — anchor event:", anchor?.originalEntry?.id); + + console.log("[generate] step 9 — generation starting for platforms:", body.platforms); + const results = await Promise.all( + body.platforms.map((platform) => + withRetry(() => generatePost(anchor, platform, body.tone), 1, platform), + ), + ); + + console.log("[generate] step 10 — results:", results); + + const posts: GeneratedPost[] = []; + const failed: Platform[] = []; + for (let i = 0; i < body.platforms.length; i++) { + const result = results[i]; + if (result !== null) { + posts.push(result); + } else { + failed.push(body.platforms[i]); + } + } + + if (posts.length === 0) { + return NextResponse.json( + { + error: "Failed to generate posts", + detail: `All platform generations failed: ${failed.join(", ")}.`, + }, + { status: 500 }, + ); + } + + if (failed.length > 0) { + return NextResponse.json({ posts, failed }, { status: 207 }); + } + + return NextResponse.json({ posts }); + } catch (error) { + console.error("[generate] FAILED at unknown step:", error); + return NextResponse.json( + { + error: "Failed to generate posts", + detail: + process.env.NODE_ENV !== "production" + ? String(error) + : "See server logs for details.", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts new file mode 100644 index 0000000..7ce57b8 --- /dev/null +++ b/src/app/api/timeline/route.ts @@ -0,0 +1,20 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { fetchTimelineEntries } from "@/server/timeline/fetchTimelineEntries"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await fetchTimelineEntries(session.user.id); + + if (!result.ok) { + const status = result.reason === "unauthenticated" ? 401 : result.reason === "no_repo" ? 400 : 500; + return NextResponse.json({ error: result.message }, { status }); + } + + return NextResponse.json(result.entries); +} diff --git a/src/app/globals.css b/src/app/globals.css index c9f019b..e3ceabf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,45 +2,117 @@ @tailwind components; @tailwind utilities; -:root { - color-scheme: dark; -} +@layer base { + *, + *::before, + *::after { + box-sizing: border-box; + } -html { - scroll-behavior: smooth; -} + html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; + } -body { - margin: 0; - min-height: 100vh; - background: - radial-gradient(circle at top, rgba(124, 58, 237, 0.12), transparent 30%), - rgb(9 9 11); - color: rgb(244 244 245); - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - sans-serif; -} + body { + @apply bg-zinc-950 text-zinc-100 font-sans; + margin: 0; + min-height: 100vh; + } + + /* Thin custom scrollbar */ + ::-webkit-scrollbar { + width: 5px; + height: 5px; + } -* { - box-sizing: border-box; + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + @apply rounded-full bg-zinc-700/60; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-zinc-600; + } + + /* Selection */ + ::selection { + @apply bg-violet-500/25 text-violet-100; + } } -*::selection { - background: rgba(139, 92, 246, 0.35); +/* ── Hero shimmer drift ─────────────────────────────────────────────────────── */ +@keyframes shimmer-drift { + 0% { transform: translate(0%, 0%) rotate(0deg); } + 33% { transform: translate(2%, -1%) rotate(0.5deg); } + 66% { transform: translate(-1%, 2%) rotate(-0.3deg); } + 100% { transform: translate(0%, 0%) rotate(0deg); } } -::-webkit-scrollbar { - width: 10px; - height: 10px; +/* ── Scroll reveal ──────────────────────────────────────────────────────────── */ +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes fadeSlideLeft { + from { opacity: 0; transform: translateX(-24px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes fadeSlideRight { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.94); } + to { opacity: 1; transform: scale(1); } } -::-webkit-scrollbar-thumb { - border-radius: 9999px; - border: 2px solid rgb(9 9 11); - background: rgb(63 63 70); +.reveal { + opacity: 0; +} +.reveal.in-view { + animation-fill-mode: both; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + animation-duration: 0.7s; } +.reveal-up.in-view { animation-name: fadeSlideUp; } +.reveal-left.in-view { animation-name: fadeSlideLeft; } +.reveal-right.in-view { animation-name: fadeSlideRight; } +.reveal-fade.in-view { animation-name: fadeIn; animation-duration: 0.9s; } +.reveal-scale.in-view { animation-name: scaleIn; animation-duration: 0.7s; } + +.delay-1 { animation-delay: 0.08s; } +.delay-2 { animation-delay: 0.16s; } +.delay-3 { animation-delay: 0.24s; } +.delay-4 { animation-delay: 0.32s; } +.delay-5 { animation-delay: 0.40s; } +.delay-6 { animation-delay: 0.48s; } + +@layer utilities { + /* Dot-grid hero background */ + .bg-dot-grid { + background-image: radial-gradient(circle, rgb(39 39 42) 1px, transparent 1px); + background-size: 26px 26px; + } + + /* Violet radial glow for hero */ + .bg-hero-glow { + background-image: radial-gradient( + ellipse 80% 55% at 50% -5%, + rgba(139, 92, 246, 0.14), + transparent + ); + } -::-webkit-scrollbar-track { - background: rgb(9 9 11); + .text-balance { + text-wrap: balance; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 008f5fb..5f2b827 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,33 @@ import type { Metadata } from "next"; +import { GeistSans } from "geist/font/sans"; +import { GeistMono } from "geist/font/mono"; +import { AppProviders } from "@/components/providers/AppProviders"; import "./globals.css"; export const metadata: Metadata = { - title: "Devlog", - description: "Turn your code into content.", + title: "Devlog — Turn your commits into content", + description: + "Devlog monitors your GitHub activity and generates platform-optimized posts for X, LinkedIn, Reddit and more. Build in public without the overhead.", + openGraph: { + title: "Devlog — Turn your commits into content", + description: + "AI-powered developer content from your GitHub activity.", + type: "website", + }, }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - <html lang="en" className="dark"> + <html + lang="en" + className={`${GeistSans.variable} ${GeistMono.variable} dark`} + > <body className="min-h-screen bg-zinc-950 text-zinc-100 antialiased"> - {children} + <AppProviders>{children}</AppProviders> </body> </html> ); diff --git a/src/app/login/LoginWithGithub.tsx b/src/app/login/LoginWithGithub.tsx new file mode 100644 index 0000000..4e0e74b --- /dev/null +++ b/src/app/login/LoginWithGithub.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { Github } from "lucide-react"; + +export function LoginWithGithub({ callbackUrl }: { callbackUrl: string }) { + return ( + <button + type="button" + onClick={() => signIn("github", { callbackUrl })} + className="flex w-full items-center justify-center gap-2 rounded-md bg-zinc-100 px-4 py-2.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-white" + > + <Github className="h-4 w-4" /> + Continue with GitHub + </button> + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..b88ab33 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/lib/auth"; +import { LoginWithGithub } from "./LoginWithGithub"; + +export const metadata = { + title: "Sign in", +}; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: { callbackUrl?: string | string[]; error?: string | string[] }; +}) { + const session = await getServerSession(authOptions); + const rawCb = searchParams.callbackUrl; + const callbackUrl = + typeof rawCb === "string" && rawCb.startsWith("/") ? rawCb : "/dashboard"; + + if (session) { + redirect(callbackUrl); + } + + const rawError = searchParams.error; + const error = typeof rawError === "string" ? rawError : undefined; + + return ( + <div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-6"> + <div className="w-full max-w-sm rounded-xl border border-zinc-800 bg-zinc-900/40 p-8"> + <div className="mb-6 text-center"> + <div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-violet-500"> + <span className="font-mono text-sm font-bold text-white">DL</span> + </div> + <h1 className="text-lg font-semibold text-zinc-100">Sign in to Devlog</h1> + <p className="mt-1 text-sm text-zinc-500"> + Use your GitHub account. We request repo access so you can pick a + repository and load commits into your timeline. + </p> + </div> + + {error && ( + <div className="mb-4 rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-center text-xs text-red-300"> + Sign-in failed ({error}). Check your environment variables and try + again. + </div> + )} + + <LoginWithGithub callbackUrl={callbackUrl} /> + + <p className="mt-6 text-center text-xs text-zinc-600"> + <Link href="/" className="text-zinc-500 hover:text-zinc-300"> + ← Back to home + </Link> + </p> + </div> + </div> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d621f6c..3c824ea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,29 +1,22 @@ import Link from "next/link"; -import { - GitBranch, - Zap, - BarChart3, - ArrowRight, - Github, - CheckCircle2, - FileText, - CalendarDays, - Lock, - GitPullRequest, -} from "lucide-react"; +import PlatformMarquee from "@/components/landing/PlatformMarquee"; +import DevlogLogo from "@/components/brand/DevlogLogo"; +import FeaturesSection from "@/components/landing/FeaturesSection"; +import PricingSection from "@/components/landing/PricingSection"; +import ScrollRevealInit from "@/components/landing/ScrollRevealInit"; +import { Github } from "lucide-react"; import { LandingHero } from "@/components/ui/shape-landing-hero"; export default function LandingPage() { return ( - <div className="min-h-screen bg-zinc-950 text-zinc-100"> + <div className="relative min-h-screen overflow-hidden bg-[#0D0D12] text-zinc-100"> + <ScrollRevealInit /> + {/* Navbar */} - <nav className="fixed inset-x-0 top-0 z-50 h-14 border-b border-zinc-800/50 bg-zinc-950/85 backdrop-blur-md"> + <nav className="fixed inset-x-0 top-0 z-50 h-14 border-b border-zinc-800/50 bg-[#0D0D12]/85 backdrop-blur-md"> <div className="mx-auto flex h-full max-w-6xl items-center justify-between px-6"> - <div className="flex items-center gap-2.5"> - <div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-violet-500"> - <span className="font-mono text-[11px] font-bold text-white">DL</span> - </div> - <span className="font-semibold tracking-tight text-zinc-100">Devlog</span> + <div className="flex items-center"> + <DevlogLogo width={140} color="#6B35D9" /> </div> <div className="hidden items-center gap-7 text-sm text-zinc-400 md:flex"> @@ -40,13 +33,13 @@ export default function LandingPage() { <div className="flex items-center gap-3"> <Link - href="/dashboard" + href="/login" className="text-sm text-zinc-400 transition-colors hover:text-zinc-100" > Sign in </Link> <Link - href="/dashboard" + href="/login" className="flex items-center gap-1.5 rounded-md bg-violet-500 px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-violet-400" > <Github className="h-3.5 w-3.5" /> @@ -57,57 +50,49 @@ export default function LandingPage() { </nav> {/* Hero — animated with framer-motion shapes */} - <LandingHero /> + <div className="relative z-10"> + <LandingHero /> + </div> {/* Features */} - <section id="features" className="px-6 py-24"> - <div className="mx-auto max-w-6xl"> - <div className="mb-14 text-center"> - <h2 className="mb-4 text-3xl font-bold tracking-tight"> - Everything you need to build in public - </h2> - <p className="mx-auto max-w-xl text-zinc-400"> - Devlog handles the full pipeline from GitHub activity to published - content — without exposing your core IP. - </p> - </div> - - <div className="grid gap-4 md:grid-cols-3"> - {features.map((feature) => ( - <div - key={feature.title} - className="group rounded-xl border border-zinc-800 bg-zinc-900/40 p-6 transition-colors hover:border-zinc-700 hover:bg-zinc-900/70" - > - <div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-zinc-800 transition-colors group-hover:bg-zinc-700/60"> - <feature.icon className="h-5 w-5 text-violet-400" /> - </div> - <h3 className="mb-2 font-semibold text-zinc-100">{feature.title}</h3> - <p className="text-sm leading-relaxed text-zinc-400"> - {feature.description} - </p> - </div> - ))} - </div> - </div> - </section> + <div id="features" className="relative z-10"> + <FeaturesSection /> + </div> {/* How it works */} - <section id="how-it-works" className="border-t border-zinc-800 px-6 py-24"> + <section id="how-it-works" className="relative z-10 border-t border-zinc-800 px-6 py-24"> <div className="mx-auto max-w-4xl"> <div className="mb-14 text-center"> - <h2 className="mb-4 text-3xl font-bold tracking-tight"> + <h2 className="mb-4 text-3xl font-bold tracking-tight reveal reveal-up"> Ship code. Devlog handles the rest. </h2> - <p className="text-zinc-400"> + <p className="text-zinc-400 reveal reveal-up delay-1"> A three-step pipeline from commit to content. </p> </div> <div className="grid gap-10 md:grid-cols-3 md:gap-6"> {steps.map((step, i) => ( - <div key={step.title} className="flex gap-4"> + <div + key={step.title} + className={`flex gap-4 reveal ${ + i === 0 + ? "reveal-left delay-1" + : i === 1 + ? "reveal-up delay-2" + : "reveal-right delay-3" + }`} + > <div className="flex flex-col items-center"> - <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-violet-500/30 bg-violet-500/10 font-mono text-sm font-bold text-violet-400"> + <div + className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full font-mono text-sm font-bold border ${ + i === 0 + ? "border-violet-500/30 bg-violet-500/10 text-violet-400" + : i === 1 + ? "border-blue-500/30 bg-blue-500/10 text-blue-400" + : "border-emerald-500/30 bg-emerald-500/10 text-emerald-400" + }`} + > {i + 1} </div> {i < steps.length - 1 && ( @@ -127,63 +112,20 @@ export default function LandingPage() { </section> {/* Platforms */} - <section className="border-t border-zinc-800 px-6 py-20"> - <div className="mx-auto max-w-3xl text-center"> - <p className="mb-7 font-mono text-[11px] uppercase tracking-widest text-zinc-600"> - Publish to - </p> - <div className="flex flex-wrap items-center justify-center gap-2.5"> - {platforms.map((p) => ( - <div - key={p} - className="rounded-full border border-zinc-800 bg-zinc-900/50 px-4 py-1.5 text-sm text-zinc-400" - > - {p} - </div> - ))} - </div> - </div> - </section> + <div className="relative z-10"> + <PlatformMarquee /> + </div> {/* CTA / Pricing */} - <section id="pricing" className="border-t border-zinc-800 px-6 py-28"> - <div className="relative mx-auto max-w-2xl overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-900/60 p-12 text-center"> - <div className="absolute inset-0 bg-hero-glow opacity-60" /> - <div className="relative"> - <div className="mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/80 px-3 py-1 text-xs text-zinc-400"> - <CheckCircle2 className="h-3 w-3 text-emerald-500" /> - No auto-posting — always your review first - </div> - <h2 className="mb-4 text-4xl font-bold tracking-tight"> - Start building in public today. - </h2> - <p className="mb-8 text-zinc-400"> - Connect one repo for free. Devlog will start turning your commits - into content immediately. - </p> - <Link - href="/dashboard" - className="inline-flex items-center gap-2 rounded-lg bg-violet-500 px-6 py-3 font-medium text-white transition-colors hover:bg-violet-400" - > - <Github className="h-4 w-4" /> - Get started free - <ArrowRight className="h-4 w-4" /> - </Link> - <p className="mt-4 text-xs text-zinc-600"> - Free tier · 1 repo included · No credit card required - </p> - </div> - </div> + <section id="pricing" className="relative z-10 border-t border-white/5"> + <PricingSection /> </section> {/* Footer */} - <footer className="border-t border-zinc-800 px-6 py-8"> + <footer className="relative z-10 border-t border-zinc-800 px-6 py-8"> <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 md:flex-row"> - <div className="flex items-center gap-2"> - <div className="flex h-5 w-5 items-center justify-center rounded bg-violet-500"> - <span className="font-mono text-[9px] font-bold text-white">DL</span> - </div> - <span className="text-sm text-zinc-500">Devlog</span> + <div className="flex items-center opacity-50"> + <DevlogLogo width={100} color="#6B35D9" /> </div> <p className="text-xs text-zinc-700"> Built by developers who believe distribution is as important as code. @@ -199,51 +141,6 @@ export default function LandingPage() { ); } -const features = [ - { - icon: GitBranch, - title: "GitHub Activity Intelligence", - description: - "Monitors commits, PRs, releases, and milestones. Focuses on meaningful product progress so content tells outcomes, not implementation details.", - }, - { - icon: Zap, - title: "AI Post Generator", - description: - "Each detected event generates platform-optimized posts with the right tone for X, LinkedIn, Reddit, Indie Hackers, and dev blogs.", - }, - { - icon: GitPullRequest, - title: "Dev Timeline", - description: - "An interactive timeline of your entire build journey. Review progress, track milestones, and see your output — even if you never post publicly.", - }, - { - icon: FileText, - title: "Content Editor", - description: - "Review and refine every post before it goes out. Rich editing, tone adjustment, AI regeneration, and thread splitting for X.", - }, - { - icon: CalendarDays, - title: "Content Calendar", - description: - "Schedule posts with drag-and-drop. Multi-platform visibility, queue management, and recurring update templates.", - }, - { - icon: Lock, - title: "Privacy Controls", - description: - "Per-repo privacy levels keep your core IP private. High, medium, or low — you control exactly what detail appears in generated content.", - }, - { - icon: BarChart3, - title: "Growth Analytics", - description: - "Track content performance across platforms. Engagement, impressions, follower growth, and best-performing posts at a glance.", - }, -]; - const steps = [ { title: "Connect your GitHub repo", diff --git a/src/components/brand/DevlogIcon.tsx b/src/components/brand/DevlogIcon.tsx new file mode 100644 index 0000000..57dc663 --- /dev/null +++ b/src/components/brand/DevlogIcon.tsx @@ -0,0 +1,44 @@ +// Icon only — no wordmark. Use for: favicon, mobile nav, small avatar, tab icon. + +interface DevlogIconProps { + size?: number; + color?: string; + className?: string; +} + +export default function DevlogIcon({ + size = 48, + color = "#6B35D9", + className = "", +}: DevlogIconProps) { + return ( + <svg + viewBox="0 0 110 110" + width={size} + height={size} + className={className} + xmlns="http://www.w3.org/2000/svg" + aria-label="Devlog icon" + role="img" + fill="none" + > + {/* Outer circle */} + <circle cx="55" cy="55" r="49" stroke={color} strokeWidth="4" fill="none" /> + + {/* Left commit tail */} + <line x1="10" y1="55" x2="35" y2="55" stroke={color} strokeWidth="4" strokeLinecap="round" /> + + {/* Commit dot */} + <circle cx="42" cy="55" r="8.5" fill={color} /> + + {/* Arc 1 — small */} + <path d="M 53 45 A 13 13 0 0 1 53 65" stroke={color} strokeWidth="4" strokeLinecap="round" /> + + {/* Arc 2 — medium */} + <path d="M 61 37 A 21 21 0 0 1 61 73" stroke={color} strokeWidth="4" strokeLinecap="round" /> + + {/* Arc 3 — large */} + <path d="M 69 29 A 29 29 0 0 1 69 81" stroke={color} strokeWidth="4" strokeLinecap="round" /> + </svg> + ); +} diff --git a/src/components/brand/DevlogLogo.tsx b/src/components/brand/DevlogLogo.tsx new file mode 100644 index 0000000..afe28bd --- /dev/null +++ b/src/components/brand/DevlogLogo.tsx @@ -0,0 +1,63 @@ +// Full wordmark — infinitely scalable SVG. No Nunito loaded; uses GeistSans +// (already loaded globally via --font-geist-sans). + +interface DevlogLogoProps { + width?: number; + color?: string; + className?: string; +} + +export default function DevlogLogo({ + width = 280, + color = "#6B35D9", + className = "", +}: DevlogLogoProps) { + const height = Math.round(width * (110 / 480)); + + return ( + <svg + viewBox="0 0 480 110" + width={width} + height={height} + className={className} + xmlns="http://www.w3.org/2000/svg" + aria-label="Devlog" + role="img" + fill="none" + > + {/* ── ICON ─────────────────────────────────────────── */} + + {/* Outer circle */} + <circle cx="55" cy="55" r="49" stroke={color} strokeWidth="3.8" fill="none" /> + + {/* Left commit tail */} + <line x1="10" y1="55" x2="34" y2="55" stroke={color} strokeWidth="3.8" strokeLinecap="round" /> + + {/* Commit dot */} + <circle cx="41" cy="55" r="8" fill={color} /> + + {/* Arc 1 — small, closest */} + <path d="M 51 45 A 13 13 0 0 1 51 65" stroke={color} strokeWidth="3.5" strokeLinecap="round" /> + + {/* Arc 2 — medium */} + <path d="M 58 37 A 21 21 0 0 1 58 73" stroke={color} strokeWidth="3.5" strokeLinecap="round" /> + + {/* Arc 3 — large, outermost */} + <path d="M 65 29 A 29 29 0 0 1 65 81" stroke={color} strokeWidth="3.5" strokeLinecap="round" /> + + {/* ── WORDMARK ─────────────────────────────────────── */} + {/* Uses GeistSans (--font-geist-sans) loaded globally in layout.tsx */} + <text + x="122" + y="77" + fontFamily="var(--font-geist-sans), 'Geist', 'Inter', system-ui, sans-serif" + fontWeight="800" + fontSize="60" + letterSpacing="-1" + fill={color} + > + Devlog + </text> + </svg> + ); +} diff --git a/src/components/github/GitHubSettingsSection.tsx b/src/components/github/GitHubSettingsSection.tsx new file mode 100644 index 0000000..56d2016 --- /dev/null +++ b/src/components/github/GitHubSettingsSection.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { signIn, signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { + Github, + CheckCircle2, + XCircle, + Loader2, + ExternalLink, + RefreshCw, + LogOut, +} from "lucide-react"; +import { cn } from "@/lib/utils/cn"; +import { RepoList } from "./RepoList"; +import type { GitHubRepo } from "@/lib/github/types"; +import type { GitHubConnectionStatus } from "@/lib/github/types"; + +interface GitHubSettingsSectionProps { + initialStatus: GitHubConnectionStatus; +} + +export function GitHubSettingsSection({ + initialStatus, +}: GitHubSettingsSectionProps) { + const router = useRouter(); + const { update: updateSession } = useSession(); + const [status, setStatus] = useState<GitHubConnectionStatus>(initialStatus); + const [repos, setRepos] = useState<GitHubRepo[]>([]); + const [loadingRepos, setLoadingRepos] = useState(false); + const [repoError, setRepoError] = useState<string | null>(null); + const [selectingRepo, setSelectingRepo] = useState(false); + + const fetchRepos = useCallback(async () => { + setLoadingRepos(true); + setRepoError(null); + try { + const res = await fetch("/api/github/repos"); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + typeof body.error === "string" ? body.error : "Failed to load repositories", + ); + } + const data: GitHubRepo[] = await res.json(); + setRepos(data); + } catch (err) { + setRepoError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoadingRepos(false); + } + }, []); + + useEffect(() => { + if (status.connected) { + fetchRepos(); + } + }, [status.connected, fetchRepos]); + + async function handleSelectRepo(repoFullName: string) { + setSelectingRepo(true); + try { + await fetch("/api/github/select-repo", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repo: repoFullName }), + }); + setStatus((prev) => ({ ...prev, selectedRepo: repoFullName })); + await updateSession(); + router.refresh(); + } finally { + setSelectingRepo(false); + } + } + + async function handleStopTracking() { + await fetch("/api/github/disconnect", { method: "POST" }); + setStatus((prev) => ({ ...prev, selectedRepo: null })); + await updateSession(); + router.refresh(); + } + + // Signed in but GitHub token missing (expired / revoked) + if (!status.connected) { + return ( + <div className="rounded-lg border border-dashed border-zinc-700 p-6 text-center"> + <Github className="mx-auto mb-3 h-8 w-8 text-zinc-600" /> + <p className="mb-1 text-sm text-zinc-400">GitHub access needed</p> + <p className="mx-auto mb-4 max-w-xs text-xs leading-relaxed text-zinc-600"> + Sign in with GitHub again so Devlog can list your repositories and load + commits. Each user connects their own account. + </p> + <button + type="button" + onClick={() => signIn("github", { callbackUrl: "/settings" })} + className="inline-flex items-center gap-2 rounded-md bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors hover:bg-zinc-700" + > + <Github className="h-3.5 w-3.5" /> + Reconnect GitHub + </button> + </div> + ); + } + + return ( + <div className="space-y-4"> + <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3"> + <div className="flex items-center gap-2.5"> + <CheckCircle2 className="h-4 w-4 text-emerald-400" /> + <div> + <p className="text-sm font-medium text-zinc-200"> + Signed in as{" "} + <span className="text-emerald-400">{status.user?.login}</span> + </p> + {status.selectedRepo && ( + <p className="mt-0.5 text-xs text-zinc-500"> + Tracking:{" "} + <span className="font-mono text-zinc-400"> + {status.selectedRepo} + </span> + </p> + )} + </div> + </div> + <div className="flex flex-wrap items-center gap-2"> + {status.selectedRepo && ( + <button + type="button" + onClick={handleStopTracking} + className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs text-zinc-400 transition-colors hover:border-zinc-600 hover:text-zinc-200" + > + Stop tracking repo + </button> + )} + <button + type="button" + onClick={() => signOut({ callbackUrl: "/login" })} + className="flex items-center gap-1.5 rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs text-zinc-400 transition-colors hover:border-red-500/30 hover:text-red-400" + > + <LogOut className="h-3 w-3" /> + Sign out + </button> + </div> + </div> + + <div> + <div className="mb-2 flex items-center justify-between"> + <p className="text-xs font-medium text-zinc-400"> + Select a repository to track + </p> + <button + type="button" + onClick={fetchRepos} + disabled={loadingRepos} + className="flex items-center gap-1 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300 disabled:opacity-50" + > + <RefreshCw + className={cn("h-3 w-3", loadingRepos && "animate-spin")} + /> + Refresh + </button> + </div> + + {repoError && ( + <div className="mb-3 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2"> + <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-red-400" /> + <p className="text-xs text-red-400">{repoError}</p> + </div> + )} + + {selectingRepo && ( + <div className="mb-3 flex items-center gap-2 text-xs text-zinc-500"> + <Loader2 className="h-3 w-3 animate-spin" /> + Selecting repository... + </div> + )} + + <RepoList + repos={repos} + selectedRepo={status.selectedRepo} + onSelect={handleSelectRepo} + loading={loadingRepos} + /> + </div> + + {status.selectedRepo && ( + <div className="flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900/60 p-3"> + <div className="flex items-center gap-2"> + <Github className="h-4 w-4 text-zinc-500" /> + <span className="font-mono text-sm text-zinc-300"> + {status.selectedRepo} + </span> + </div> + <div className="flex items-center gap-2"> + <a + href={`https://github.com/${status.selectedRepo}`} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1 text-xs text-zinc-500 transition-colors hover:text-zinc-300" + > + <ExternalLink className="h-3 w-3" /> + Open + </a> + <a + href="/timeline" + className="text-xs text-violet-400 transition-colors hover:text-violet-300" + > + View timeline → + </a> + </div> + </div> + )} + </div> + ); +} diff --git a/src/components/github/RepoList.tsx b/src/components/github/RepoList.tsx new file mode 100644 index 0000000..028bea1 --- /dev/null +++ b/src/components/github/RepoList.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { Github, Star, Lock, Globe, Search, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils/cn"; +import type { GitHubRepo } from "@/lib/github/types"; + +interface RepoListProps { + repos: GitHubRepo[]; + selectedRepo: string | null; + onSelect: (repoFullName: string) => void; + loading?: boolean; +} + +export function RepoList({ + repos, + selectedRepo, + onSelect, + loading, +}: RepoListProps) { + const [search, setSearch] = useState(""); + + const filtered = repos.filter((repo) => { + const q = search.toLowerCase().trim(); + if (!q) return true; + return ( + repo.full_name.toLowerCase().includes(q) || + repo.description?.toLowerCase().includes(q) + ); + }); + + if (loading) { + return ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-5 w-5 animate-spin text-zinc-500" /> + <span className="ml-2 text-sm text-zinc-500">Loading repositories...</span> + </div> + ); + } + + if (repos.length === 0) { + return ( + <div className="py-6 text-center"> + <Github className="mx-auto mb-2 h-6 w-6 text-zinc-600" /> + <p className="text-sm text-zinc-500">No repositories found</p> + </div> + ); + } + + return ( + <div className="space-y-3"> + <div className="relative"> + <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-500" /> + <input + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search repositories..." + className="w-full rounded-md border border-zinc-700 bg-zinc-800/60 py-1.5 pl-8 pr-3 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-violet-500/40 focus:outline-none focus:ring-1 focus:ring-violet-500/20" + /> + </div> + + <div className="max-h-72 space-y-1 overflow-y-auto pr-1"> + {filtered.map((repo) => { + const isSelected = selectedRepo === repo.full_name; + return ( + <button + key={repo.id} + onClick={() => onSelect(repo.full_name)} + className={cn( + "flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-all duration-150", + isSelected + ? "border-violet-500/40 bg-violet-500/5" + : "border-zinc-800 hover:border-zinc-700 hover:bg-zinc-800/40", + )} + > + <div className="mt-0.5 flex-shrink-0"> + {repo.private ? ( + <Lock className="h-3.5 w-3.5 text-amber-500/70" /> + ) : ( + <Globe className="h-3.5 w-3.5 text-zinc-500" /> + )} + </div> + + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <p className="truncate text-sm font-medium text-zinc-200"> + {repo.full_name} + </p> + {isSelected && ( + <span className="flex-shrink-0 rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-medium text-violet-400"> + Selected + </span> + )} + </div> + {repo.description && ( + <p className="mt-0.5 truncate text-xs text-zinc-500"> + {repo.description} + </p> + )} + <div className="mt-1.5 flex items-center gap-3 text-[11px] text-zinc-600"> + {repo.language && <span>{repo.language}</span>} + {repo.stargazers_count > 0 && ( + <span className="flex items-center gap-0.5"> + <Star className="h-3 w-3" /> + {repo.stargazers_count} + </span> + )} + {repo.pushed_at && ( + <span> + pushed{" "} + {new Date(repo.pushed_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + </span> + )} + </div> + </div> + </button> + ); + })} + {filtered.length === 0 && ( + <p className="py-4 text-center text-sm text-zinc-600"> + No repos match “{search}” + </p> + )} + </div> + </div> + ); +} diff --git a/src/components/github/index.ts b/src/components/github/index.ts new file mode 100644 index 0000000..e21ee56 --- /dev/null +++ b/src/components/github/index.ts @@ -0,0 +1,2 @@ +export { GitHubSettingsSection } from "./GitHubSettingsSection"; +export { RepoList } from "./RepoList"; diff --git a/src/components/landing/AnimatedTerminal.tsx b/src/components/landing/AnimatedTerminal.tsx new file mode 100644 index 0000000..46a4f30 --- /dev/null +++ b/src/components/landing/AnimatedTerminal.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { TERMINAL_SCENARIOS } from "./terminalData"; +import { useTypewriter } from "@/hooks/useTypewriter"; + +type Stage = + | "idle" + | "commit" + | "analyzing" + | "generating" + | "done" + | "resetting"; + +function AnalyzingDots({ active }: { active: boolean }) { + const [dots, setDots] = useState("generating outcome-focused summary"); + + useEffect(() => { + if (!active) { + setDots("generating outcome-focused summary..."); + return; + } + let count = 0; + const interval = setInterval(() => { + count = (count + 1) % 4; + setDots("generating outcome-focused summary" + ".".repeat(count)); + }, 400); + return () => clearInterval(interval); + }, [active]); + + return <span className="text-sm text-white/30">{dots}</span>; +} + +export default function AnimatedTerminal() { + const [scenarioIndex, setScenarioIndex] = useState(0); + const [stage, setStage] = useState<Stage>("idle"); + const [visible, setVisible] = useState(true); + + const scenario = TERMINAL_SCENARIOS[scenarioIndex]; + + // Kick off the loop on mount + useEffect(() => { + const t = setTimeout(() => setStage("idle"), 500); + return () => clearTimeout(t); + }, []); + + // Typewriter hooks + const commitTyped = useTypewriter( + scenario.commit.message, + 22, + 0, + stage === "commit" || + stage === "analyzing" || + stage === "generating" || + stage === "done", + ); + + const postTyped = useTypewriter( + scenario.generatedPost, + 25, + 0, + stage === "generating" || stage === "done", + ); + + // Stage timer: pure-timer transitions + useEffect(() => { + let t: ReturnType<typeof setTimeout>; + + if (stage === "idle") { + t = setTimeout(() => setStage("commit"), 800); + } else if (stage === "analyzing") { + t = setTimeout(() => setStage("generating"), 2800); + } else if (stage === "done") { + t = setTimeout(() => setStage("resetting"), 3000); + } else if (stage === "resetting") { + setVisible(false); + t = setTimeout(() => { + setScenarioIndex((i) => (i + 1) % TERMINAL_SCENARIOS.length); + setStage("idle"); + setVisible(true); + }, 400); + } + + return () => clearTimeout(t); + }, [stage]); + + // commit → analyzing: wait for typewriter + 600ms + useEffect(() => { + if (stage !== "commit" || !commitTyped.isDone) return; + const t = setTimeout(() => setStage("analyzing"), 600); + return () => clearTimeout(t); + }, [stage, commitTyped.isDone]); + + // generating → done: wait for typewriter + 1200ms + useEffect(() => { + if (stage !== "generating" || !postTyped.isDone) return; + const t = setTimeout(() => setStage("done"), 1200); + return () => clearTimeout(t); + }, [stage, postTyped.isDone]); + + const showCommit = + stage === "commit" || + stage === "analyzing" || + stage === "generating" || + stage === "done"; + const showAnalyzing = + stage === "analyzing" || stage === "generating" || stage === "done"; + const showPost = stage === "generating" || stage === "done"; + + return ( + <div + className="overflow-hidden rounded-xl border border-white/[0.08] bg-[#0D0D12] text-left shadow-2xl shadow-black/60 backdrop-blur-sm" + style={{ fontFamily: "monospace" }} + > + {/* Title bar */} + <div className="flex items-center gap-1.5 border-b border-white/[0.06] bg-white/[0.02] px-4 py-2.5"> + <div className="h-2.5 w-2.5 rounded-full bg-white/20" /> + <div className="h-2.5 w-2.5 rounded-full bg-white/20" /> + <div className="h-2.5 w-2.5 rounded-full bg-white/20" /> + <span className="ml-2 text-[11px] text-white/30"> + devlog · activity feed + </span> + </div> + + {/* Body */} + <div + className="min-h-[220px] space-y-5 p-5" + style={{ opacity: visible ? 1 : 0, transition: "opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1)" }} + > + {/* Stage 1 — commit detected */} + {showCommit && ( + <div className="flex gap-3"> + <span className="mt-0.5 shrink-0 text-amber-400/80 text-sm">→</span> + <div> + <span className="text-amber-400/80 text-sm">commit detected</span> + <span className="ml-2 text-sm text-white/30"> + · {scenario.commit.repo} + </span> + <div className="mt-0.5 text-sm text-white/50"> + “{commitTyped.displayed} + {!commitTyped.isDone && ( + <span className="animate-pulse">▋</span> + )} + ” + </div> + </div> + </div> + )} + + {/* Stage 2 — AI analyzing */} + {showAnalyzing && ( + <div className="flex items-start gap-3"> + <span className="mt-0.5 shrink-0 text-violet-400/80 text-sm">⚡</span> + <div> + <span className="text-sm text-violet-400/80">AI analyzing</span> + <span className="text-sm text-white/30"> · </span> + <AnalyzingDots active={stage === "analyzing"} /> + </div> + </div> + )} + + {/* Stage 3 — post generated */} + {showPost && ( + <div className="flex gap-3"> + <span className="mt-0.5 shrink-0 text-emerald-400/80 text-sm">◎</span> + <div> + <span className="text-sm text-emerald-400/80">post generated</span> + <span className="ml-2 text-sm text-white/30"> + · {scenario.platform} + </span> + <div className="mt-0.5 text-sm leading-relaxed text-white/60"> + “{postTyped.displayed} + {!postTyped.isDone && ( + <span className="animate-pulse">▋</span> + )} + ” + </div> + </div> + </div> + )} + </div> + </div> + ); +} diff --git a/src/components/landing/FeatureCard.tsx b/src/components/landing/FeatureCard.tsx new file mode 100644 index 0000000..67d15b0 --- /dev/null +++ b/src/components/landing/FeatureCard.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import { useInView } from "@/hooks/useInView"; + +// `animation` is a render function so the card can inject `active` at render +// time — a plain ReactNode cannot receive new props after creation. +type Props = { + icon: React.ReactNode; + title: string; + description: string; + animation: (active: boolean) => React.ReactNode; + isAutoActive?: boolean; // controls border highlight only (not animation) + colSpan?: "1" | "2"; +}; + +export default function FeatureCard({ + icon, + title, + description, + animation, + isAutoActive = false, + colSpan = "1", +}: Props) { + const [isHovered, setIsHovered] = useState(false); + const { ref, inView } = useInView({ threshold: 0.25 }); + + // Animation plays whenever the card is in the viewport. + // Hover + auto-cycle only influence the visual border/glow enhancement. + const isHighlighted = isHovered || isAutoActive; + + return ( + <div + ref={ref} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + // Scroll-reveal + hover lift (combined into a single transform) + opacity: inView ? 1 : 0, + // Hover glow & lift + border: isHighlighted + ? "1px solid rgba(124,58,237,0.45)" + : "1px solid rgba(255,255,255,0.07)", + background: isHighlighted + ? "rgba(124,58,237,0.06)" + : "rgba(255,255,255,0.02)", + boxShadow: isHighlighted + ? "0 0 28px rgba(124,58,237,0.07)" + : "none", + transform: inView + ? isHighlighted + ? "translateY(-2px)" + : "translateY(0)" + : "translateY(16px)", + transition: + "opacity 0.5s cubic-bezier(0.4,0,0.2,1), transform 0.35s cubic-bezier(0.4,0,0.2,1), border 0.35s cubic-bezier(0.4,0,0.2,1), background 0.35s cubic-bezier(0.4,0,0.2,1), box-shadow 0.35s cubic-bezier(0.4,0,0.2,1)", + borderRadius: 16, + padding: 24, + display: "flex", + flexDirection: "column", + gap: 16, + cursor: "pointer", + gridColumn: colSpan === "2" ? "span 2" : undefined, + }} + > + {/* Top row: icon badge + title */} + <div style={{ display: "flex", alignItems: "center", gap: 12 }}> + <div + style={{ + width: 36, + height: 36, + borderRadius: 8, + background: "rgba(124,58,237,0.2)", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "#a78bfa", + flexShrink: 0, + }} + > + {icon} + </div> + <h3 style={{ color: "#ffffff", fontWeight: 600, fontSize: 15, margin: 0 }}> + {title} + </h3> + </div> + + {/* Animation area — always rendered; plays when inView=true */} + <div + style={{ + height: 120, + opacity: inView ? 1 : 0, + transform: inView ? "translateY(0)" : "translateY(12px)", + transition: + "opacity 0.7s cubic-bezier(0.4,0,0.2,1), transform 0.7s cubic-bezier(0.4,0,0.2,1)", + }} + > + {animation(inView)} + </div> + + {/* Description — always visible below the animation, staggered reveal */} + <p + style={{ + opacity: inView ? 1 : 0, + transform: inView ? "translateY(0)" : "translateY(8px)", + transition: + "opacity 0.6s cubic-bezier(0.4,0,0.2,1) 0.15s, transform 0.6s cubic-bezier(0.4,0,0.2,1) 0.15s", + fontSize: 14, + lineHeight: 1.6, + color: "rgba(255,255,255,0.5)", + margin: 0, + }} + > + {description} + </p> + </div> + ); +} diff --git a/src/components/landing/FeaturesSection.tsx b/src/components/landing/FeaturesSection.tsx new file mode 100644 index 0000000..fa97f2b --- /dev/null +++ b/src/components/landing/FeaturesSection.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + GitBranch, + Zap, + GitPullRequest, + FileText, + CalendarDays, + Lock, + BarChart3, +} from "lucide-react"; +import FeatureCard from "@/components/landing/FeatureCard"; +import { + CommitGraphAnimation, + PostGeneratorAnimation, + TimelineAnimation, + ContentEditorAnimation, + ContentCalendarAnimation, + PrivacyAnimation, + AnalyticsAnimation, +} from "@/components/landing/featureAnimations"; +import { useInView } from "@/hooks/useInView"; + +// ── Feature definitions ─────────────────────────────────────────────────────── + +const FEATURES: { + id: string; + icon: React.ReactNode; + title: string; + description: string; + animation: (active: boolean) => React.ReactNode; + colSpan: "1" | "2"; +}[] = [ + { + id: "github", + icon: <GitBranch className="h-4 w-4" />, + title: "GitHub Activity Intelligence", + description: + "Monitors commits, PRs, releases, and milestones. Focuses on meaningful product progress so content tells outcomes, not implementation details.", + animation: (active) => <CommitGraphAnimation active={active} />, + colSpan: "1", + }, + { + id: "ai-post", + icon: <Zap className="h-4 w-4" />, + title: "AI Post Generator", + description: + "Each detected event generates platform-optimized posts with the right tone for X, LinkedIn, Reddit, Indie Hackers, and dev blogs.", + animation: (active) => <PostGeneratorAnimation active={active} />, + colSpan: "2", + }, + { + id: "timeline", + icon: <GitPullRequest className="h-4 w-4" />, + title: "Dev Timeline", + description: + "An interactive timeline of your entire build journey. Review progress, track milestones, and see your output — even if you never post publicly.", + animation: (active) => <TimelineAnimation active={active} />, + colSpan: "1", + }, + { + id: "editor", + icon: <FileText className="h-4 w-4" />, + title: "Content Editor", + description: + "Review and refine every post before it goes out. Rich editing, tone adjustment, AI regeneration, and thread splitting for X.", + animation: (active) => <ContentEditorAnimation active={active} />, + colSpan: "1", + }, + { + id: "calendar", + icon: <CalendarDays className="h-4 w-4" />, + title: "Content Calendar", + description: + "Schedule posts with drag-and-drop. Multi-platform visibility, queue management, and recurring update templates.", + animation: (active) => <ContentCalendarAnimation active={active} />, + colSpan: "1", + }, + { + id: "privacy", + icon: <Lock className="h-4 w-4" />, + title: "Privacy Controls", + description: + "Per-repo privacy levels keep your core IP private. High, medium, or low — you control exactly what detail appears in generated content.", + animation: (active) => <PrivacyAnimation active={active} />, + colSpan: "1", + }, + { + id: "analytics", + icon: <BarChart3 className="h-4 w-4" />, + title: "Growth Analytics", + description: + "Track content performance across platforms. Engagement, impressions, follower growth, and best-performing posts at a glance.", + animation: (active) => <AnalyticsAnimation active={active} />, + colSpan: "1", + }, +]; + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function FeaturesSection() { + // Auto-cycle controls which card gets the purple border highlight. + // It always runs — hover only redirects which card is highlighted. + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setActiveIndex((i) => (i + 1) % FEATURES.length); + }, 3500); + return () => clearInterval(interval); + }, []); + + // Scroll-reveal for the heading + const { ref: headingRef, inView: headingInView } = useInView({ threshold: 0.2 }); + + return ( + <section className="px-4 py-24"> + <div className="mx-auto max-w-6xl"> + {/* Heading — scroll-reveal */} + <div + ref={headingRef} + style={{ + opacity: headingInView ? 1 : 0, + transform: headingInView ? "translateY(0)" : "translateY(12px)", + transition: + "opacity 0.6s cubic-bezier(0.4,0,0.2,1), transform 0.6s cubic-bezier(0.4,0,0.2,1)", + }} + className="mb-16 text-center" + > + <h2 + className="mb-4 text-4xl font-bold" + style={{ + background: "linear-gradient(180deg, #ffffff 60%, rgba(167,139,250,0.7) 100%)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + backgroundClip: "text", + }} + > + Everything you need to build in public + </h2> + <p className="mx-auto max-w-xl text-white/50"> + Devlog handles the full pipeline from GitHub activity to published + content — without exposing your core IP. + </p> + </div> + + {/* Bento grid — cards cascade in via staggered transitionDelay */} + <div className="grid grid-cols-1 gap-4 md:grid-cols-3"> + {FEATURES.map((feature, i) => ( + <div + key={feature.id} + className={ + feature.colSpan === "2" ? "md:col-span-2" : "md:col-span-1" + } + // Redirect the highlight to the hovered card instantly + onMouseEnter={() => setActiveIndex(i)} + style={{ + // Cascade: each card delays its inView transition by 80ms × index + transitionDelay: `${i * 80}ms`, + }} + > + <FeatureCard + icon={feature.icon} + title={feature.title} + description={feature.description} + animation={feature.animation} + colSpan={feature.colSpan} + isAutoActive={activeIndex === i} + /> + </div> + ))} + </div> + </div> + </section> + ); +} diff --git a/src/components/landing/GitGraphBackground.tsx b/src/components/landing/GitGraphBackground.tsx new file mode 100644 index 0000000..a9d7f51 --- /dev/null +++ b/src/components/landing/GitGraphBackground.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type Node = { + id: number; + x: number; + y: number; + vx: number; + lane: number; + opacity: number; + radius: number; + isPulsing: boolean; + pulsePhase: number; + age: number; + maxAge: number; +}; + +type Edge = { + fromId: number; + toId: number; + opacity: number; + color: string; +}; + +let nextId = 0; + +function spawnNode(canvasHeight: number, lane: number): Node { + const laneCount = 6; + const laneY = (canvasHeight / (laneCount + 1)) * (lane + 1); + const jitter = (Math.random() - 0.5) * (canvasHeight / laneCount) * 0.35; + + return { + id: nextId++, + x: -20, + y: laneY + jitter, + vx: 0.28 + Math.random() * 0.17, + lane, + opacity: 0, + radius: 3 + Math.random() * 3, + isPulsing: Math.random() < 0.15, + pulsePhase: Math.random() * Math.PI * 2, + age: 0, + maxAge: 280 + Math.floor(Math.random() * 101), + }; +} + +export default function GitGraphBackground() { + const canvasRef = useRef<HTMLCanvasElement>(null); + const nodesRef = useRef<Node[]>([]); + const edgesRef = useRef<Edge[]>([]); + const rafRef = useRef<number>(0); + const edgeCountRef = useRef<Map<number, number>>(new Map()); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + if (!ctx) return; + + let width = window.innerWidth; + let height = window.innerHeight; + canvas.width = width; + canvas.height = height; + + const handleResize = () => { + width = window.innerWidth; + height = window.innerHeight; + canvas.width = width; + canvas.height = height; + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(document.documentElement); + + function tryAddEdge(newNode: Node) { + const maxEdgesPerNode = 2; + if ((edgeCountRef.current.get(newNode.id) ?? 0) >= maxEdgesPerNode) return; + + for (const existing of nodesRef.current) { + if (existing.id === newNode.id) continue; + if ((edgeCountRef.current.get(existing.id) ?? 0) >= maxEdgesPerNode) continue; + + const laneDiff = Math.abs(existing.lane - newNode.lane); + if (laneDiff > 1) continue; + + const dx = existing.x - newNode.x; + const dy = existing.y - newNode.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 180) continue; + + edgesRef.current.push({ + fromId: newNode.id, + toId: existing.id, + opacity: 0, + color: "rgba(124, 58, 237, 0.25)", + }); + + edgeCountRef.current.set(newNode.id, (edgeCountRef.current.get(newNode.id) ?? 0) + 1); + edgeCountRef.current.set(existing.id, (edgeCountRef.current.get(existing.id) ?? 0) + 1); + break; + } + } + + function maintainNodes() { + const target = 28 + Math.floor(Math.random() * 8); + while (nodesRef.current.length < target) { + const lane = Math.floor(Math.random() * 6); + const node = spawnNode(height, lane); + nodesRef.current.push(node); + tryAddEdge(node); + } + } + + function frame() { + ctx.clearRect(0, 0, width, height); + + // Update nodes + nodesRef.current = nodesRef.current.filter((n) => { + n.x += n.vx; + n.age += 1; + n.pulsePhase += 0.04; + + if (n.age < 60) n.opacity = n.age / 60; + else if (n.age > n.maxAge - 60) n.opacity = Math.max(0, (n.maxAge - n.age) / 60); + else n.opacity = 1; + + if (n.x > width + 40) { + edgeCountRef.current.delete(n.id); + return false; + } + return true; + }); + + // Prune edges whose nodes are gone + const liveIds = new Set(nodesRef.current.map((n) => n.id)); + edgesRef.current = edgesRef.current.filter( + (e) => liveIds.has(e.fromId) && liveIds.has(e.toId) + ); + + const nodeMap = new Map(nodesRef.current.map((n) => [n.id, n])); + + // Draw edges + let edgeFrames = 0; + for (const edge of edgesRef.current) { + const a = nodeMap.get(edge.fromId); + const b = nodeMap.get(edge.toId); + if (!a || !b) continue; + + const minOpacity = Math.min(a.opacity, b.opacity); + edge.opacity = Math.min(edge.opacity + 1 / 40, minOpacity); + + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.strokeStyle = `rgba(124, 58, 237, ${edge.opacity * 0.25})`; + ctx.lineWidth = 1; + ctx.stroke(); + + edgeFrames++; + } + + // Draw nodes + for (const n of nodesRef.current) { + if (n.isPulsing) { + // Outer glow + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius * (2.5 + Math.sin(n.pulsePhase) * 0.8), 0, Math.PI * 2); + ctx.fillStyle = `rgba(124, 58, 237, ${n.opacity * 0.08})`; + ctx.fill(); + + // Middle ring + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius * 1.6, 0, Math.PI * 2); + ctx.fillStyle = `rgba(124, 58, 237, ${n.opacity * 0.2})`; + ctx.fill(); + + // Inner filled + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(167, 139, 250, ${n.opacity * 0.9})`; + ctx.fill(); + } else { + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(124, 58, 237, ${n.opacity * 0.55})`; + ctx.fill(); + } + } + + // Occasional branch arc — 0.3% chance + if (Math.random() < 0.003 && nodesRef.current.length > 0) { + const src = nodesRef.current[Math.floor(Math.random() * nodesRef.current.length)]; + const dir = Math.random() < 0.5 ? -1 : 1; + const len = 80 + Math.random() * 60; + const bend = dir * (40 + Math.random() * 20); + + ctx.beginPath(); + ctx.moveTo(src.x, src.y); + ctx.bezierCurveTo( + src.x + len * 0.4, src.y + bend * 0.6, + src.x + len * 0.7, src.y + bend, + src.x + len, src.y + bend + ); + ctx.strokeStyle = "rgba(124, 58, 237, 0.12)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Spawn to maintain node count + maintainNodes(); + + // Radial gradient overlay — darkens edges, keeps focus center + const gradient = ctx.createRadialGradient( + width / 2, height / 2, 0, + width / 2, height / 2, width * 0.7 + ); + gradient.addColorStop(0, "rgba(13, 13, 18, 0)"); + gradient.addColorStop(1, "rgba(13, 13, 18, 0.4)"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + rafRef.current = requestAnimationFrame(frame); + } + + rafRef.current = requestAnimationFrame(frame); + + return () => { + cancelAnimationFrame(rafRef.current); + resizeObserver.disconnect(); + }; + }, []); + + return ( + <canvas + ref={canvasRef} + style={{ + position: "fixed", + top: 0, + left: 0, + width: "100%", + height: "100%", + zIndex: 0, + pointerEvents: "none", + }} + /> + ); +} diff --git a/src/components/landing/PlatformMarquee.tsx b/src/components/landing/PlatformMarquee.tsx new file mode 100644 index 0000000..16c06a3 --- /dev/null +++ b/src/components/landing/PlatformMarquee.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; + +const PLATFORMS = [ + { name: "X (Twitter)", icon: "𝕏", color: "#ffffff" }, + { name: "LinkedIn", icon: "in", color: "#0A66C2" }, + { name: "Reddit", icon: "👾", color: "#FF4500" }, + { name: "Indie Hackers", icon: "⚡", color: "#0049ff" }, + { name: "Dev.to", icon: "DEV", color: "#ffffff" }, + { name: "Hashnode", icon: "◈", color: "#2962FF" }, + { name: "Product Hunt", icon: "🐱", color: "#DA552F" }, +]; + +const DOUBLED = [...PLATFORMS, ...PLATFORMS]; + +type Platform = (typeof PLATFORMS)[number]; + +function PillCard({ platform }: { platform: Platform }) { + return ( + <div + className="flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-5 py-3 backdrop-blur-sm" + style={{ flexShrink: 0 }} + > + <span + style={{ + color: platform.color, + fontFamily: "monospace", + fontSize: "14px", + fontWeight: 700, + }} + > + {platform.icon} + </span> + <span className="whitespace-nowrap text-sm font-medium text-white/80"> + {platform.name} + </span> + </div> + ); +} + +export default function PlatformMarquee() { + const [isHovered, setIsHovered] = useState(false); + + return ( + <section className="w-full py-16"> + <style>{` + @keyframes marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } + } + `}</style> + + <p className="mb-8 text-center text-xs font-medium uppercase tracking-[0.2em] text-white/30 reveal reveal-fade"> + Publish to + </p> + + <div + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="reveal reveal-up delay-1" + > + <div + style={{ + overflow: "hidden", + width: "100%", + maskImage: + "linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%)", + WebkitMaskImage: + "linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%)", + }} + > + <div + style={{ + display: "flex", + flexDirection: "row", + flexWrap: "nowrap", + width: "max-content", + gap: "16px", + animation: "marquee 28s linear infinite", + animationPlayState: isHovered ? "paused" : "running", + willChange: "transform", + }} + > + {DOUBLED.map((platform, i) => ( + <PillCard key={i} platform={platform} /> + ))} + </div> + </div> + </div> + </section> + ); +} diff --git a/src/components/landing/PricingSection.tsx b/src/components/landing/PricingSection.tsx new file mode 100644 index 0000000..b828eaa --- /dev/null +++ b/src/components/landing/PricingSection.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState } from "react"; + +const FREE_FEATURES = [ + { text: "1 connected GitHub repo", included: true }, + { text: "10 AI post generations / month", included: true }, + { text: "X (Twitter) and LinkedIn", included: true }, + { text: "Casual and professional tone", included: true }, + { text: "7-day timeline history", included: true }, + { text: "All platforms (Reddit, IH, Dev.to…)", included: false }, + { text: "Unlimited generations", included: false }, + { text: "Privacy controls", included: false }, + { text: "Content calendar & scheduling", included: false }, + { text: "Analytics dashboard", included: false }, +]; + +const PRO_FEATURES = [ + "Unlimited GitHub repos", + "Unlimited AI post generations", + "All platforms — X, LinkedIn, Reddit, Indie Hackers, Dev.to, Hashnode, Product Hunt", + "All tone modes including feedback-seeking & educational", + "Full timeline history", + "Privacy controls — High / Medium / Low per repo", + "Content calendar & scheduling", + "Analytics dashboard", + "Priority generation speed", +]; + +function CheckIcon() { + return ( + <svg width="8" height="8" viewBox="0 0 8 8" fill="none"> + <path + d="M1 4l2 2 4-4" + stroke="#a78bfa" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function CrossIcon() { + return ( + <svg width="8" height="8" viewBox="0 0 8 8" fill="none"> + <path + d="M2 2l4 4M6 2l-4 4" + stroke="#ffffff30" + strokeWidth="1.5" + strokeLinecap="round" + /> + </svg> + ); +} + +export default function PricingSection() { + const [annual, setAnnual] = useState(false); + + return ( + <section className="mx-auto max-w-5xl px-4 py-24"> + {/* Heading */} + <div className="mb-12 text-center"> + <h2 className="mb-4 text-4xl font-bold text-white reveal reveal-up"> + Simple, honest pricing + </h2> + <p className="mx-auto max-w-md text-lg text-white/50 reveal reveal-up delay-1"> + Start free. Upgrade when Devlog becomes part of your workflow. + </p> + + {/* Annual / Monthly toggle */} + <div className="mt-8 flex items-center justify-center gap-3 reveal reveal-fade delay-2"> + <span + className={`text-sm ${!annual ? "text-white" : "text-white/40"}`} + style={{ transition: "color 0.3s cubic-bezier(0.4, 0, 0.2, 1)" }} + > + Monthly + </span> + <button + onClick={() => setAnnual((a) => !a)} + aria-label="Toggle billing period" + className="relative h-6 w-12 rounded-full" + style={{ + background: annual ? "#7C3AED" : "rgba(255,255,255,0.1)", + transition: "background 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + }} + > + <div + className="absolute top-1 h-4 w-4 rounded-full bg-white" + style={{ + left: annual ? "28px" : "4px", + transition: "left 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + }} + /> + </button> + <span + className={`text-sm ${annual ? "text-white" : "text-white/40"}`} + style={{ transition: "color 0.3s cubic-bezier(0.4, 0, 0.2, 1)" }} + > + Annual + <span className="ml-2 rounded-full border border-green-400/20 bg-green-400/10 px-2 py-0.5 text-xs font-medium text-green-400"> + Save 28% + </span> + </span> + </div> + </div> + + {/* Cards */} + <div className="grid grid-cols-1 items-stretch gap-6 md:grid-cols-2"> + + {/* ── FREE CARD ─────────────────────────────────────── */} + <div className="flex flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-8 reveal reveal-left delay-2"> + {/* Plan label + price */} + <div className="mb-6"> + <p className="mb-2 text-sm font-medium uppercase tracking-widest text-white/50"> + Free + </p> + <div className="flex items-end gap-2"> + <span className="text-5xl font-bold text-white">$0</span> + <span className="mb-2 text-white/40">/ forever</span> + </div> + <p className="mt-2 text-sm text-white/30">No credit card required</p> + </div> + + {/* CTA */} + <a + href="/login" + className="mb-8 w-full rounded-xl border border-white/15 py-3 text-center text-sm font-medium text-white/80 hover:border-white/30 hover:text-white" + style={{ transition: "all 0.28s cubic-bezier(0.4, 0, 0.2, 1)" }} + > + Get started free + </a> + + {/* Divider */} + <div className="mb-6 border-t border-white/5" /> + + {/* Feature list */} + <ul className="flex flex-1 flex-col gap-3"> + {FREE_FEATURES.map((item, i) => ( + <li key={i} className="flex items-center gap-3"> + <span + className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full ${ + item.included ? "bg-purple-500/20" : "bg-white/5" + }`} + > + {item.included ? <CheckIcon /> : <CrossIcon />} + </span> + <span + className={`text-sm ${ + item.included + ? "text-white/70" + : "text-white/25 line-through decoration-white/15" + }`} + > + {item.text} + </span> + </li> + ))} + </ul> + </div> + + {/* ── PRO CARD ──────────────────────────────────────── */} + <div + className="relative flex flex-col overflow-hidden rounded-2xl p-8 reveal reveal-right delay-3" + style={{ + background: + "linear-gradient(135deg, rgba(124,58,237,0.15) 0%, rgba(124,58,237,0.05) 100%)", + border: "1px solid rgba(124,58,237,0.4)", + boxShadow: + "0 0 60px rgba(124,58,237,0.12), inset 0 1px 0 rgba(255,255,255,0.05)", + }} + > + {/* Corner glow */} + <div + className="pointer-events-none absolute right-0 top-0 h-64 w-64 rounded-full" + style={{ + background: + "radial-gradient(circle, rgba(124,58,237,0.12) 0%, transparent 70%)", + transform: "translate(30%, -30%)", + }} + /> + + {/* Popular badge */} + <div className="absolute right-6 top-6"> + <span className="rounded-full bg-purple-600 px-3 py-1 text-xs font-semibold text-white"> + Most popular + </span> + </div> + + {/* Plan label + price */} + <div className="mb-6"> + <p className="mb-2 text-sm font-medium uppercase tracking-widest text-purple-400"> + Pro + </p> + <div className="flex items-end gap-2"> + <span + key={annual ? "annual" : "monthly"} + className="text-5xl font-bold text-white" + style={{ animation: "fadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1) both" }} + > + {annual ? "$10" : "$14"} + </span> + <span className="mb-2 text-white/40">/ month</span> + </div> + <p className="mt-2 text-sm text-white/30"> + {annual + ? "Billed $120 annually — you save $48" + : "Or $10/mo billed annually — save 28%"} + </p> + </div> + + {/* CTA */} + <a + href="/login" + className="relative mb-8 w-full rounded-xl py-3 text-center text-sm font-semibold text-white" + style={{ + background: "linear-gradient(135deg, #7C3AED, #6D28D9)", + boxShadow: "0 4px 20px rgba(124,58,237,0.4)", + transition: "all 0.28s cubic-bezier(0.4, 0, 0.2, 1)", + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.boxShadow = + "0 4px 28px rgba(124,58,237,0.6)"; + (e.currentTarget as HTMLElement).style.transform = + "translateY(-1px)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.boxShadow = + "0 4px 20px rgba(124,58,237,0.4)"; + (e.currentTarget as HTMLElement).style.transform = + "translateY(0)"; + }} + > + Start Pro free for 7 days → + </a> + + {/* Divider */} + <div className="mb-6 border-t border-purple-500/15" /> + + {/* Feature list */} + <ul className="flex flex-1 flex-col gap-3"> + {PRO_FEATURES.map((text, i) => ( + <li key={i} className="flex items-start gap-3"> + <span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-purple-500/25"> + <CheckIcon /> + </span> + <span className="text-sm text-white/75">{text}</span> + </li> + ))} + </ul> + + {/* Trust note */} + <p className="mt-6 text-center text-xs text-white/25"> + 7-day free trial · Cancel anytime · No questions asked + </p> + </div> + </div> + + {/* Bottom reassurance */} + <p className="mt-10 text-center text-sm text-white/25"> + Already used by developers building in public. No lock-in, no surprises. + </p> + </section> + ); +} diff --git a/src/components/landing/ScrollRevealInit.tsx b/src/components/landing/ScrollRevealInit.tsx new file mode 100644 index 0000000..e9c09a4 --- /dev/null +++ b/src/components/landing/ScrollRevealInit.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect } from "react"; +import { initScrollReveal } from "@/lib/scrollReveal"; + +export default function ScrollRevealInit() { + useEffect(() => { + const cleanup = initScrollReveal(); + return cleanup; + }, []); + + return null; +} diff --git a/src/components/landing/featureAnimations/index.tsx b/src/components/landing/featureAnimations/index.tsx new file mode 100644 index 0000000..fcf6645 --- /dev/null +++ b/src/components/landing/featureAnimations/index.tsx @@ -0,0 +1,740 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; + +// ── useCycleLoop ────────────────────────────────────────────────────────────── +// Drives CSS-keyframe animations through repeating cycles. +// +// • cycleKey — increments on every cycle; use as `key` on the animated +// container so React remounts it and CSS animations restart. +// • visible — controls an opacity fade-out between cycles. +// +// 200ms entrance delay prevents a jarring snap when the card scrolls in. +// When active→false everything resets instantly so the next in-view play +// starts clean. + +function useCycleLoop(active: boolean, cycleDuration: number, fadeDuration = 400) { + const [cycleKey, setCycleKey] = useState(0); + const [visible, setVisible] = useState(false); + const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); + + useEffect(() => { + function clearTimers() { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + } + + if (!active) { + clearTimers(); + setVisible(false); + setCycleKey(0); + return; + } + + function startCycle() { + setVisible(true); + setCycleKey((k) => k + 1); + const t1 = setTimeout(() => setVisible(false), cycleDuration); + const t2 = setTimeout(startCycle, cycleDuration + fadeDuration); + timersRef.current = [t1, t2]; + } + + const entrance = setTimeout(startCycle, 200); + timersRef.current = [entrance]; + return clearTimers; + }, [active, cycleDuration, fadeDuration]); + + return { cycleKey, visible }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. CommitGraphAnimation +// Branch draws left → right; 4 commit dots pop in with 300ms stagger; +// labels fade in above/below. Holds 2 s then restarts. +// ───────────────────────────────────────────────────────────────────────────── + +const CG_COMMITS = [ + { pct: 10, label: "feat", color: "#7C3AED", above: true, delay: "400ms" }, + { pct: 36, label: "fix", color: "#f59e0b", above: false, delay: "700ms" }, + { pct: 62, label: "perf", color: "#10b981", above: true, delay: "1000ms" }, + { pct: 87, label: "release", color: "#ffffff", above: false, delay: "1300ms" }, +]; +// Last label visible ≈ 1500ms → hold 2000ms → cycle = 3500ms + +export function CommitGraphAnimation({ active }: { active: boolean }) { + const { cycleKey, visible } = useCycleLoop(active, 3500); + + return ( + <div + style={{ + width: 280, height: 120, position: "relative", overflow: "hidden", + opacity: visible ? 1 : 0, + transition: "opacity 400ms ease-in-out", + }} + > + <style>{` + @keyframes cg-line { + from { transform: scaleX(0); } + to { transform: scaleX(1); } + } + @keyframes cg-pop { + 0% { opacity: 0; transform: translateX(-50%) scale(0); } + 100% { opacity: 1; transform: translateX(-50%) scale(1); } + } + @keyframes cg-lbl { + from { opacity: 0; } + to { opacity: 1; } + } + `}</style> + + {cycleKey > 0 && ( + <div key={cycleKey} style={{ position: "absolute", inset: 0 }}> + {/* Branch line */} + <div + style={{ + position: "absolute", + top: 59, left: 16, right: 16, height: 2, + background: "rgba(124,58,237,0.45)", + transformOrigin: "left center", + animation: "cg-line 900ms cubic-bezier(0.4,0,0.2,1) forwards", + }} + /> + {CG_COMMITS.map((c, i) => ( + <div key={i}> + {/* Dot */} + <div + style={{ + position: "absolute", + left: `calc(16px + (100% - 32px) * ${c.pct / 100})`, + top: 51, width: 16, height: 16, + borderRadius: "50%", + background: c.color, + border: "2px solid #0D0D12", + opacity: 0, + animation: `cg-pop 300ms cubic-bezier(0.4,0,0.2,1) ${c.delay} forwards`, + }} + /> + {/* Label */} + <span + style={{ + position: "absolute", + left: `calc(16px + (100% - 32px) * ${c.pct / 100})`, + top: c.above ? 22 : 84, + transform: "translateX(-50%)", + fontSize: 10, fontFamily: "monospace", color: c.color, + whiteSpace: "nowrap", opacity: 0, + animation: `cg-lbl 300ms cubic-bezier(0.4,0,0.2,1) calc(${c.delay} + 200ms) forwards`, + }} + > + {c.label} + </span> + </div> + ))} + </div> + )} + </div> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. PostGeneratorAnimation +// Badge appears → text types at 40ms/char → cursor blinks 1800ms → restart. +// ───────────────────────────────────────────────────────────────────────────── + +const PG_TEXT = + "Just shipped scoring algorithm — evaluates startup ideas across market size & competition. Day 14. 🚀"; + +export function PostGeneratorAnimation({ active }: { active: boolean }) { + const [displayed, setDisplayed] = useState(""); + const [loopVisible, setLoopVisible] = useState(false); + const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); + const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); + + useEffect(() => { + function clearAll() { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + + if (!active) { + clearAll(); + setDisplayed(""); + setLoopVisible(false); + return; + } + + function runCycle() { + setLoopVisible(true); + setDisplayed(""); + let idx = 0; + + intervalRef.current = setInterval(() => { + idx += 1; + setDisplayed(PG_TEXT.slice(0, idx)); + + if (idx >= PG_TEXT.length) { + clearInterval(intervalRef.current!); + intervalRef.current = null; + + // Hold cursor blink 1800ms, fade 400ms, then restart + const t1 = setTimeout(() => setLoopVisible(false), 1800); + const t2 = setTimeout(runCycle, 2300); + timersRef.current.push(t1, t2); + } + }, 40); + } + + const entrance = setTimeout(runCycle, 200); + timersRef.current = [entrance]; + return clearAll; + }, [active]); + + return ( + <div + style={{ + width: 280, height: 120, + padding: "10px 12px", boxSizing: "border-box", overflow: "hidden", + opacity: loopVisible ? 1 : 0, + transition: "opacity 400ms ease-in-out", + }} + > + <style>{` + @keyframes pg-badge { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes pg-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } + } + `}</style> + + {/* Platform badge */} + <div + style={{ + display: "inline-flex", alignItems: "center", gap: 5, + background: "rgba(255,255,255,0.06)", + border: "1px solid rgba(255,255,255,0.12)", + borderRadius: 4, padding: "2px 8px", marginBottom: 8, + fontSize: 10, color: "#e5e7eb", fontFamily: "monospace", + animation: "pg-badge 300ms cubic-bezier(0.4,0,0.2,1) forwards", + }} + > + <span style={{ fontSize: 12 }}>𝕏</span> Twitter + </div> + + {/* Typed text + blinking cursor */} + <div style={{ fontSize: 11, color: "#d1d5db", fontFamily: "monospace", lineHeight: 1.55 }}> + {displayed} + <span + style={{ + display: "inline-block", width: 1, height: "1em", + background: "#7C3AED", marginLeft: 1, verticalAlign: "text-bottom", + animation: "pg-blink 1s step-end infinite", + }} + /> + </div> + </div> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. TimelineAnimation +// 3 rows slide in from the left, staggered 300ms. Holds 2s then restarts. +// ───────────────────────────────────────────────────────────────────────────── + +const TL_ROWS = [ + { dot: "#f59e0b", text: "feat: auth system", delay: "0ms" }, + { dot: "#7C3AED", text: "fix: null pointer", delay: "300ms" }, + { dot: "#10b981", text: "release: v1.2.0", delay: "600ms" }, +]; +// Last row done at 600 + 500 = 1100ms → hold 2000ms → cycle = 3100ms + +export function TimelineAnimation({ active }: { active: boolean }) { + const { cycleKey, visible } = useCycleLoop(active, 3100); + + return ( + <div + style={{ + width: 280, height: 120, + display: "flex", flexDirection: "column", justifyContent: "center", + padding: "0 18px", boxSizing: "border-box", overflow: "hidden", + opacity: visible ? 1 : 0, + transition: "opacity 400ms ease-in-out", + }} + > + <style>{` + @keyframes tl-in { + from { opacity: 0; transform: translateX(-18px); } + to { opacity: 1; transform: translateX(0); } + } + `}</style> + + {cycleKey > 0 && ( + <div key={cycleKey} style={{ display: "flex", flexDirection: "column", gap: 11 }}> + {TL_ROWS.map((row, i) => ( + <div + key={i} + style={{ + display: "flex", alignItems: "center", gap: 10, + opacity: 0, + animation: `tl-in 500ms cubic-bezier(0.4,0,0.2,1) ${row.delay} forwards`, + }} + > + <div + style={{ + width: 8, height: 8, borderRadius: "50%", + background: row.dot, flexShrink: 0, + }} + /> + <span style={{ fontSize: 11, fontFamily: "monospace", color: "#d1d5db", whiteSpace: "nowrap" }}> + {row.text} + </span> + </div> + ))} + </div> + )} + </div> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. ContentEditorAnimation +// AI rewrites a sentence in 4 phases, 800ms apart. Holds 2s then loops. +// ───────────────────────────────────────────────────────────────────────────── + +export function ContentEditorAnimation({ active }: { active: boolean }) { + const [phase, setPhase] = useState(0); + const [loopVisible, setLoopVisible] = useState(false); + const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); + + useEffect(() => { + function clearTimers() { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + } + + if (!active) { + clearTimers(); + setPhase(0); + setLoopVisible(false); + return; + } + + function runCycle() { + setLoopVisible(true); + setPhase(1); + + const t1 = setTimeout(() => setPhase(2), 800); + const t2 = setTimeout(() => setPhase(3), 1600); + const t3 = setTimeout(() => setPhase(4), 2400); + // Hold 2000ms after phase 4 → fade at 4400ms + const t4 = setTimeout(() => setLoopVisible(false), 4400); + // Reset state and restart after fade completes (400ms gap) + const t5 = setTimeout(() => { + setPhase(0); + const restart = setTimeout(runCycle, 200); + timersRef.current.push(restart); + }, 4850); + timersRef.current = [t1, t2, t3, t4, t5]; + } + + const entrance = setTimeout(runCycle, 200); + timersRef.current = [entrance]; + return clearTimers; + }, [active]); + + return ( + <div + style={{ + width: 280, height: 120, + display: "flex", alignItems: "center", justifyContent: "center", + padding: "0 18px", boxSizing: "border-box", + opacity: loopVisible ? 1 : 0, + transition: "opacity 400ms ease-in-out", + }} + > + {phase >= 1 && ( + <div style={{ fontFamily: "monospace", fontSize: 12, color: "#e5e7eb", lineHeight: 2 }}> + {"The "} + <span + style={ + phase >= 2 + ? { textDecoration: "line-through", color: "#6b7280", fontSize: 11 } + : {} + } + > + bug + </span> + {phase >= 3 && <span style={{ color: "#10b981" }}> critical issue</span>} + {" is "} + {phase < 4 ? ( + <span>fixed</span> + ) : ( + <> + <span style={{ textDecoration: "line-through", color: "#6b7280", fontSize: 11 }}> + fixed + </span> + <span style={{ color: "#10b981" }}> resolved</span> + </> + )} + {"."} + </div> + )} + </div> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. ContentCalendarAnimation +// 4×3 grid — colored platform overlays light up in an 80ms/cell wave. +// Holds 1800ms then restarts. +// ───────────────────────────────────────────────────────────────────────────── + +const CC_GRID: (string | null)[] = [ + "#7C3AED", null, "#ffffff", null, + null, "#f97316", null, "#7C3AED", + "#ffffff", null, "#f97316", null, +]; +// 12 cells × 80ms + 300ms anim ≈ 1260ms → hold 1800ms → cycle = 3060ms + +export function ContentCalendarAnimation({ active }: { active: boolean }) { + const { cycleKey, visible } = useCycleLoop(active, 3060); + + return ( + <div + style={{ + width: 280, height: 120, + display: "flex", alignItems: "center", justifyContent: "center", + opacity: visible ? 1 : 0, + transition: "opacity 400ms ease-in-out", + }} + > + <style>{` + @keyframes cc-cell { + from { opacity: 0.08; } + to { opacity: 1; } + } + @keyframes cc-dot { + from { opacity: 0; } + to { opacity: 0.7; } + } + `}</style> + + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(4, 1fr)", + gridTemplateRows: "repeat(3, 1fr)", + gap: 6, width: 212, height: 96, + }} + > + {CC_GRID.map((color, i) => ( + <div + key={i} + style={{ + borderRadius: 4, + background: "rgba(255,255,255,0.07)", + border: "1px solid rgba(255,255,255,0.08)", + position: "relative", overflow: "hidden", + }} + > + {cycleKey > 0 && color && ( + // Wrapper with key — remounts on each cycle to restart CSS anims + <div key={`${i}-${cycleKey}`} style={{ position: "absolute", inset: 0 }}> + {/* Platform color overlay */} + <div + style={{ + position: "absolute", inset: 0, borderRadius: 3, + background: color, opacity: 0.08, + animation: `cc-cell 300ms cubic-bezier(0.4,0,0.2,1) ${i * 80}ms forwards`, + }} + /> + {/* Platform dot */} + <div + style={{ + position: "absolute", inset: 0, + display: "flex", alignItems: "center", justifyContent: "center", + opacity: 0, + animation: `cc-dot 300ms cubic-bezier(0.4,0,0.2,1) ${i * 80 + 150}ms forwards`, + }} + > + <div + style={{ + width: 5, height: 5, borderRadius: "50%", + background: color === "#ffffff" ? "#0D0D12" : "#ffffff", + opacity: 0.6, + }} + /> + </div> + </div> + )} + </div> + ))} + </div> + </div> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. PrivacyAnimation +// Shield fills bottom-to-top (1200ms); scan line sweeps text; labels reveal. +// Holds 2s then restarts. +// ───────────────────────────────────────────────────────────────────────────── + +const PV_LINES = [ + { revealDelay: "950ms", text: "████ redacted ████", muted: true }, + { revealDelay: "1100ms", text: "████ redacted ████", muted: true }, + { revealDelay: "1250ms", text: "outcome: shipped ✓", muted: false }, +]; +// Last reveal ≈ 1550ms → hold 2000ms → cycle = 3600ms + +export function PrivacyAnimation({ active }: { active: boolean }) { + const { cycleKey, visible } = useCycleLoop(active, 3600); + // Stable unique ID per component instance for the SVG clipPath + const clipId = useRef(`pv-${Math.random().toString(36).slice(2, 7)}`).current; + + return ( + <div + style={{ + width: 280, height: 120, + display: "flex", alignItems: "center", gap: 18, + padding: "0 16px", boxSizing: "border-box", + opacity: visible ? 1 : 0, + transition: "opacity 400ms ease-in-out", + }} + > + <style>{` + @keyframes pv-fill { + from { transform: translateY(68px); } + to { transform: translateY(0); } + } + @keyframes pv-scan { + from { top: 0; opacity: 0.85; } + to { top: 62px; opacity: 0; } + } + @keyframes pv-reveal { + from { opacity: 0; } + to { opacity: 1; } + } + `}</style> + + {/* Shield SVG */} + <div style={{ flexShrink: 0, width: 44, height: 60 }}> + <svg viewBox="0 0 44 60" width={44} height={60} fill="none" xmlns="http://www.w3.org/2000/svg"> + <defs> + <clipPath id={clipId}> + <path d="M22 2 L4 10 L4 30 C4 44 12 54 22 58 C32 54 40 44 40 30 L40 10 Z" /> + </clipPath> + </defs> + + {/* Fill rect — remounts on each cycle via key, restarting the animation */} + {cycleKey > 0 && ( + <rect + key={cycleKey} + x={0} y={0} width={44} height={68} + fill="#7C3AED" opacity={0.38} + clipPath={`url(#${clipId})`} + style={{ + transform: "translateY(68px)", + animation: "pv-fill 1200ms cubic-bezier(0.4,0,0.2,1) forwards", + }} + /> + )} + + {/* Outline always visible */} + <path + d="M22 2 L4 10 L4 30 C4 44 12 54 22 58 C32 54 40 44 40 30 L40 10 Z" + stroke="#7C3AED" strokeWidth={2.4} fill="none" + /> + </svg> + </div> + + {/* Text lines + scanning line */} + <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9, position: "relative" }}> + {/* Scan line — remounts with cycleKey */} + {cycleKey > 0 && ( + <div + key={cycleKey} + style={{ + position: "absolute", left: 0, right: 0, height: 2, + background: "rgba(124,58,237,0.75)", + animation: "pv-scan 700ms ease-in-out 200ms forwards", + }} + /> + )} + + {PV_LINES.map((line, i) => ( + <div key={i} style={{ display: "flex", flexDirection: "column", gap: 3 }}> + {/* Gray bar */} + <div + style={{ + height: 7, borderRadius: 2, + background: visible ? "rgba(124,58,237,0.22)" : "rgba(255,255,255,0.08)", + }} + /> + {/* Revealed label — remounts on each cycle */} + {cycleKey > 0 && ( + <span + key={cycleKey} + style={{ + fontSize: 9, fontFamily: "monospace", + color: line.muted ? "#6b7280" : "#10b981", + opacity: 0, + animation: `pv-reveal 300ms cubic-bezier(0.4,0,0.2,1) ${line.revealDelay} forwards`, + }} + > + {line.text} + </span> + )} + </div> + ))} + </div> + </div> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 7. AnalyticsAnimation +// 5 bars grow up (700ms, 120ms stagger), hold 2s, shrink back (500ms), restart. +// Uses phase-based CSS transitions so the shrink-down is explicit. +// ───────────────────────────────────────────────────────────────────────────── + +const AN_BARS = [ + { pct: 40, label: "Mon" }, + { pct: 65, label: "Tue" }, + { pct: 55, label: "Wed" }, + { pct: 80, label: "Thu" }, + { pct: 70, label: "Fri" }, +]; +const AN_MAX_H = 70; // max bar height px +const AN_TALLEST = 3; // index of Thu (80%) + +// Timing: +// phase 0 → 1: entrance 200ms +// bars done: last bar (i=4) at 4×120 + 700 = 1180ms → enter hold at 1300ms +// phase 1 → 2 at 1300ms (hold) +// phase 2 → 3 at 3300ms (shrink) +// phase 3 → 0 at 3850ms, restart after 100ms = 3950ms total cycle + +export function AnalyticsAnimation({ active }: { active: boolean }) { + const [phase, setPhase] = useState<0 | 1 | 2 | 3>(0); + const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); + + useEffect(() => { + function clearTimers() { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + } + + if (!active) { + clearTimers(); + setPhase(0); + return; + } + + function runCycle() { + setPhase(1); + const t1 = setTimeout(() => setPhase(2), 1300); + const t2 = setTimeout(() => setPhase(3), 3300); + const t3 = setTimeout(() => { + setPhase(0); + const restart = setTimeout(runCycle, 100); + timersRef.current.push(restart); + }, 3850); + timersRef.current = [t1, t2, t3]; + } + + const entrance = setTimeout(runCycle, 200); + timersRef.current = [entrance]; + return clearTimers; + }, [active]); + + const isUp = phase === 1 || phase === 2; + + return ( + <div + style={{ + width: 280, height: 120, + display: "flex", flexDirection: "column", alignItems: "center", + justifyContent: "flex-end", padding: "0 14px 8px", boxSizing: "border-box", + }} + > + <div + style={{ + display: "flex", alignItems: "flex-end", gap: 8, + height: AN_MAX_H + 24, width: "100%", maxWidth: 230, + position: "relative", + }} + > + {/* +24% label — fades in during hold phase */} + <span + style={{ + position: "absolute", + bottom: 24 + Math.round((AN_BARS[AN_TALLEST].pct / 100) * AN_MAX_H) + 4, + left: `calc(${((AN_TALLEST + 0.5) / AN_BARS.length) * 100}%)`, + transform: "translateX(-50%)", + fontSize: 10, fontFamily: "monospace", color: "#10b981", + whiteSpace: "nowrap", + opacity: phase === 2 ? 1 : 0, + transition: "opacity 400ms cubic-bezier(0.4,0,0.2,1)", + }} + > + +24% + </span> + + {AN_BARS.map((bar, i) => { + const targetH = Math.round((bar.pct / 100) * AN_MAX_H); + return ( + <div + key={i} + style={{ + flex: 1, display: "flex", + flexDirection: "column", alignItems: "center", gap: 5, + }} + > + <div + style={{ + width: "100%", height: AN_MAX_H, + display: "flex", flexDirection: "column", justifyContent: "flex-end", + }} + > + <div + style={{ + width: "100%", + height: isUp ? targetH : 0, + background: "linear-gradient(to top, #7C3AED, #a78bfa)", + borderRadius: "3px 3px 0 0", + position: "relative", overflow: "hidden", + // Growing: staggered per bar. Shrinking: all together. + transition: + phase === 1 + ? `height 700ms cubic-bezier(0.4,0,0.2,1) ${i * 120}ms` + : phase === 3 + ? "height 500ms ease-in-out" + : "none", + }} + > + <div + style={{ + position: "absolute", top: 0, left: 0, right: 0, height: 3, + background: "rgba(255,255,255,0.28)", + borderRadius: "3px 3px 0 0", + }} + /> + </div> + </div> + <span style={{ fontSize: 9, color: "#6b7280", fontFamily: "monospace" }}> + {bar.label} + </span> + </div> + ); + })} + </div> + </div> + ); +} diff --git a/src/components/landing/terminalData.ts b/src/components/landing/terminalData.ts new file mode 100644 index 0000000..5ca0844 --- /dev/null +++ b/src/components/landing/terminalData.ts @@ -0,0 +1,62 @@ +export type TerminalScenario = { + commit: { + repo: string; + message: string; + branch: string; + }; + platform: "X (Twitter)" | "LinkedIn" | "Reddit"; + generatedPost: string; +}; + +export const TERMINAL_SCENARIOS: TerminalScenario[] = [ + { + commit: { + repo: "startup-validator", + message: "feat: implement scoring algorithm for market size analysis", + branch: "main", + }, + platform: "X (Twitter)", + generatedPost: + "Day 14 building my AI startup validator. Just shipped the scoring engine — it now evaluates ideas across market size, competition & founder fit. First real signal that this thing works.", + }, + { + commit: { + repo: "portfolio-site", + message: "fix: resolve hydration mismatch on dark mode toggle", + branch: "main", + }, + platform: "LinkedIn", + generatedPost: + "Spent 3 hours debugging a Next.js hydration issue today — the kind that only shows up in production. Turned out dark mode state was being read differently on server vs client. The fix was a single line. The lesson was worth the 3 hours.", + }, + { + commit: { + repo: "devlog-app", + message: "feat: add GitHub OAuth and per-user repo selection", + branch: "feature/github-integration", + }, + platform: "Reddit", + generatedPost: + "Built GitHub OAuth into my side project this weekend. Went down a rabbit hole with NextAuth adapter sessions vs JWT — database sessions are way more reliable for storing access tokens. Sharing in case anyone else hits the same wall.", + }, + { + commit: { + repo: "ml-classifier", + message: "perf: reduce inference time by 40% with model quantization", + branch: "main", + }, + platform: "X (Twitter)", + generatedPost: + "Cut ML inference time by 40% today using INT8 quantization. Same accuracy, fraction of the compute. If you're running models in prod and haven't tried this yet — worth an afternoon.", + }, + { + commit: { + repo: "saas-boilerplate", + message: "feat: stripe webhook handling + subscription tier enforcement", + branch: "main", + }, + platform: "LinkedIn", + generatedPost: + "Finally wired up Stripe webhooks properly. Not glamorous work — but subscription tier enforcement is the difference between a demo and a real product. Crossed that line today.", + }, +]; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index fe16b82..2dc04f5 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,9 +2,12 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import DevlogLogo from "@/components/brand/DevlogLogo"; +import { signOut, useSession } from "next-auth/react"; import { LayoutDashboard, GitBranch, + Sparkles, FileText, CalendarDays, BarChart3, @@ -12,6 +15,8 @@ import { ChevronDown, Github, ArrowLeft, + CheckCircle2, + LogOut, } from "lucide-react"; import { cn } from "@/lib/utils/cn"; import type { NavItem } from "@/types/nav"; @@ -19,9 +24,10 @@ import type { NavItem } from "@/types/nav"; const mainNavItems: NavItem[] = [ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { label: "Timeline", href: "/timeline", icon: GitBranch }, - { label: "Content", href: "/content", icon: FileText, disabled: true, soon: true }, - { label: "Calendar", href: "/calendar", icon: CalendarDays, disabled: true, soon: true }, - { label: "Analytics", href: "/analytics", icon: BarChart3, disabled: true, soon: true }, + { label: "Generate", href: "/generate", icon: Sparkles }, + { label: "Content", href: "#", icon: FileText, disabled: true, soon: true }, + { label: "Calendar", href: "#", icon: CalendarDays, disabled: true, soon: true }, + { label: "Analytics", href: "#", icon: BarChart3, disabled: true, soon: true }, ]; const bottomNavItems: NavItem[] = [ @@ -30,6 +36,13 @@ const bottomNavItems: NavItem[] = [ export function Sidebar() { const pathname = usePathname(); + const { data: session, status } = useSession(); + const repoName = session?.user?.selectedGithubRepo ?? null; + const loading = status === "loading"; + + const displayName = + session?.user?.name ?? session?.user?.email ?? "Signed in"; + const initial = displayName.charAt(0).toUpperCase(); return ( <aside className="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-zinc-800 bg-zinc-900"> @@ -40,25 +53,42 @@ export function Sidebar() { className="flex items-center gap-2.5 group" title="Back to home" > - <div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-violet-500 transition-colors group-hover:bg-violet-400"> - <span className="font-mono text-[11px] font-bold text-white tracking-tight">DL</span> - </div> - <span className="font-semibold text-zinc-100 tracking-tight group-hover:text-violet-300 transition-colors"> - Devlog - </span> + <DevlogLogo + width={120} + color="#6B35D9" + className="transition-opacity group-hover:opacity-80" + /> </Link> - <button className="ml-auto text-zinc-600 hover:text-zinc-400 transition-colors"> + <button type="button" className="ml-auto text-zinc-600 hover:text-zinc-400 transition-colors"> <ChevronDown className="h-3.5 w-3.5" /> </button> </div> {/* Repo selector */} <div className="px-3 pt-3"> - <button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 transition-colors border border-dashed border-zinc-800 hover:border-zinc-700"> - <Github className="h-3.5 w-3.5 flex-shrink-0" /> - <span className="truncate">No repo connected</span> - <ChevronDown className="ml-auto h-3 w-3 flex-shrink-0" /> - </button> + {loading ? ( + <div className="flex w-full items-center gap-2 rounded-md border border-zinc-800 px-2 py-1.5 text-xs text-zinc-600"> + <span className="truncate">Loading…</span> + </div> + ) : repoName ? ( + <Link + href="/settings" + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-emerald-400 hover:bg-zinc-800 transition-colors border border-emerald-500/20 bg-emerald-500/5 hover:border-emerald-500/30" + > + <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0" /> + <span className="truncate font-mono">{repoName.split("/").pop()}</span> + <ChevronDown className="ml-auto h-3 w-3 flex-shrink-0" /> + </Link> + ) : ( + <Link + href="/settings" + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-violet-400 hover:bg-zinc-800 transition-colors border border-violet-500/20 bg-violet-500/5 hover:border-violet-500/30" + > + <Github className="h-3.5 w-3.5 flex-shrink-0" /> + <span className="truncate">Select a repo</span> + <ChevronDown className="ml-auto h-3 w-3 flex-shrink-0" /> + </Link> + )} </div> {/* Section label */} @@ -83,7 +113,6 @@ export function Sidebar() { <SidebarNavLink key={item.href} item={item} pathname={pathname} /> ))} - {/* Back to landing */} <Link href="/" className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-zinc-600 hover:text-zinc-400 hover:bg-zinc-800 transition-all duration-150 group" @@ -92,17 +121,36 @@ export function Sidebar() { <span>Back to site</span> </Link> - {/* User profile */} - <button className="mt-1 flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-sm hover:bg-zinc-800 transition-colors group"> - <div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-violet-500/20 text-xs font-semibold text-violet-300"> - BL - </div> - <div className="flex-1 text-left min-w-0"> - <div className="text-xs font-medium text-zinc-300 truncate">bruceliu</div> - <div className="text-[10px] text-zinc-600 truncate">Free plan</div> + <div className="mt-1 space-y-1 rounded-md px-2 py-2 hover:bg-zinc-800/60 transition-colors"> + <div className="flex items-center gap-2.5"> + <div className="flex h-7 w-7 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-violet-500/20 text-xs font-semibold text-violet-300"> + {session?.user?.image ? ( + // eslint-disable-next-line @next/next/no-img-element + <img + src={session.user.image} + alt="" + className="h-full w-full object-cover" + /> + ) : ( + initial + )} + </div> + <div className="min-w-0 flex-1 text-left"> + <div className="truncate text-xs font-medium text-zinc-300"> + {displayName} + </div> + <div className="truncate text-[10px] text-zinc-600">GitHub account</div> + </div> </div> - <ChevronDown className="h-3 w-3 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" /> - </button> + <button + type="button" + onClick={() => signOut({ callbackUrl: "/login" })} + className="flex w-full items-center gap-1.5 rounded px-1 py-1 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300" + > + <LogOut className="h-3 w-3" /> + Sign out + </button> + </div> </div> </aside> ); diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index 5beb986..49b2b95 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -7,32 +7,41 @@ interface TopbarProps { export function Topbar({ title, description }: TopbarProps) { return ( - <header className="sticky top-0 z-20 border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur"> - <div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4"> - <div className="min-w-0"> - <p className="text-[11px] font-medium uppercase tracking-[0.24em] text-violet-400"> - Devlog - </p> - <h1 className="mt-1 text-xl font-semibold text-zinc-100">{title}</h1> - {description ? ( - <p className="mt-1 text-sm text-zinc-500">{description}</p> - ) : null} - </div> + <header className="flex h-14 items-center gap-4 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur px-6 sticky top-0 z-30"> + {/* Page title */} + <div className="flex-1 min-w-0"> + <h1 className="text-sm font-semibold text-zinc-100 leading-tight"> + {title} + </h1> + {description && ( + <p className="text-[11px] text-zinc-500 truncate mt-0.5">{description}</p> + )} + </div> + + {/* Actions */} + <div className="flex items-center gap-2"> + {/* Search */} + <button className="hidden md:flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-500 hover:border-zinc-700 hover:text-zinc-400 transition-colors"> + <Search className="h-3 w-3" /> + <span>Search...</span> + <kbd className="ml-2 font-mono text-[10px] text-zinc-700 border border-zinc-700 rounded px-1"> + ⌘K + </kbd> + </button> + + {/* Notifications */} + <button className="relative flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 transition-colors"> + <Bell className="h-4 w-4" /> + <span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-violet-500 ring-2 ring-zinc-950" /> + </button> - <div className="flex items-center gap-3"> - <button className="hidden items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 sm:flex"> - <Search className="h-4 w-4" /> - Search - <span className="rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-500"> - cmd+k - </span> - </button> + {/* Divider */} + <div className="h-5 w-px bg-zinc-800" /> - <button className="relative rounded-lg border border-zinc-800 bg-zinc-900 p-2 text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200"> - <Bell className="h-4 w-4" /> - <span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-violet-500" /> - </button> - </div> + {/* Avatar */} + <button className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-500/20 text-xs font-semibold text-violet-300 hover:bg-violet-500/30 transition-colors"> + BL + </button> </div> </header> ); diff --git a/src/components/postGenerator/EventSelector.tsx b/src/components/postGenerator/EventSelector.tsx new file mode 100644 index 0000000..9b40c63 --- /dev/null +++ b/src/components/postGenerator/EventSelector.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { GitCommit, GitMerge, GitPullRequest, Tag } from "lucide-react"; +import { cn } from "@/lib/utils/cn"; +import type { TimelineEntry } from "@/features/timeline/types"; + +type Props = { + entries: TimelineEntry[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}; + +// ── Badge config — mirrors TimelineEntryCard colour palette ──────────────── + +type BadgeCfg = { + icon: React.ElementType; + badge: string; + iconColor: string; + label: string; +}; + +function getBadgeCfg(entry: TimelineEntry): BadgeCfg { + if (entry.type === "commit") { + return { + icon: GitCommit, + badge: "border-amber-500/20 bg-amber-500/10 text-amber-400", + iconColor: "text-amber-400", + label: "commit", + }; + } + if (entry.type === "pr") { + if (entry.state === "merged") { + return { + icon: GitMerge, + badge: "border-violet-500/20 bg-violet-500/10 text-violet-400", + iconColor: "text-violet-400", + label: `PR #${entry.prNumber}`, + }; + } + return { + icon: GitPullRequest, + badge: "border-blue-500/20 bg-blue-500/10 text-blue-400", + iconColor: "text-blue-400", + label: `PR #${entry.prNumber}`, + }; + } + // release + return { + icon: Tag, + badge: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400", + iconColor: "text-emerald-400", + label: entry.version, + }; +} + +// ── Date formatting ───────────────────────────────────────────────────────── + +function formatShortDate(dateIso: string): string { + const d = new Date(dateIso); + if (isNaN(d.getTime())) return ""; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +// ── Card ─────────────────────────────────────────────────────────────────── + +type CardProps = { + entry: TimelineEntry; + selected: boolean; + onToggle: () => void; +}; + +function EventCard({ entry, selected, onToggle }: CardProps) { + const cfg = getBadgeCfg(entry); + const Icon = cfg.icon; + const summary = + entry.summary.length > 100 + ? entry.summary.slice(0, 100).trimEnd() + "…" + : entry.summary; + + return ( + <div + role="checkbox" + aria-checked={selected} + tabIndex={0} + onClick={onToggle} + onKeyDown={(e) => { + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle(); + } + }} + className={cn( + "flex cursor-pointer select-none items-start gap-3 rounded-xl border p-3.5 transition-all duration-150", + selected + ? "border-violet-500/50 bg-violet-500/5 hover:border-violet-400/60" + : "border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-900/70", + )} + > + {/* Left: icon */} + <div + className={cn( + "mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-zinc-800", + selected ? "border-violet-500/30 bg-violet-500/10" : "bg-zinc-800/60", + )} + > + <Icon className={cn("h-3.5 w-3.5", cfg.iconColor)} /> + </div> + + {/* Middle: content */} + <div className="min-w-0 flex-1"> + {/* Badge + date */} + <div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1"> + <span + className={cn( + "inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wider", + cfg.badge, + )} + > + {cfg.label} + </span> + <span className="font-mono text-[11px] text-zinc-500"> + {formatShortDate(entry.dateIso)} + </span> + </div> + + {/* Title */} + <p className="text-sm font-medium leading-snug text-zinc-100"> + {entry.title} + </p> + + {/* Summary */} + <p className="mt-1 text-xs leading-relaxed text-zinc-500">{summary}</p> + </div> + + {/* Right: checkbox */} + <div className="mt-0.5 flex-shrink-0"> + <div + className={cn( + "flex h-4 w-4 items-center justify-center rounded border transition-colors", + selected + ? "border-violet-500 bg-violet-500" + : "border-zinc-600 bg-transparent", + )} + > + {selected && ( + <svg + viewBox="0 0 12 12" + fill="none" + className="h-2.5 w-2.5" + aria-hidden + > + <path + d="M2 6l3 3 5-5" + stroke="white" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + )} + </div> + </div> + </div> + ); +} + +// ── EventSelector ────────────────────────────────────────────────────────── + +export function EventSelector({ entries, selectedIds, onChange }: Props) { + const selectedSet = new Set(selectedIds); + const allSelected = + entries.length > 0 && entries.every((e) => selectedSet.has(e.id)); + + function toggle(id: string) { + if (selectedSet.has(id)) { + onChange(selectedIds.filter((s) => s !== id)); + } else { + onChange([...selectedIds, id]); + } + } + + function toggleAll() { + if (allSelected) { + onChange([]); + } else { + onChange(entries.map((e) => e.id)); + } + } + + if (entries.length === 0) { + return ( + <p className="text-sm text-zinc-500"> + No timeline events found. Select a GitHub repo in{" "} + <a href="/settings" className="text-violet-400 hover:text-violet-300 underline"> + Settings + </a>{" "} + to populate your timeline. + </p> + ); + } + + return ( + <div className="space-y-3"> + {/* Select all / Deselect all */} + <div className="flex items-center justify-between"> + <span className="text-xs text-zinc-500"> + {selectedIds.length} of {entries.length} selected + </span> + <button + type="button" + onClick={toggleAll} + className="text-xs text-violet-400 transition-colors hover:text-violet-300" + > + {allSelected ? "Deselect all" : "Select all"} + </button> + </div> + + {/* Cards */} + <div className="space-y-2"> + {entries.map((entry) => ( + <EventCard + key={entry.id} + entry={entry} + selected={selectedSet.has(entry.id)} + onToggle={() => toggle(entry.id)} + /> + ))} + </div> + </div> + ); +} diff --git a/src/components/postGenerator/OptionsPanel.tsx b/src/components/postGenerator/OptionsPanel.tsx new file mode 100644 index 0000000..19df473 --- /dev/null +++ b/src/components/postGenerator/OptionsPanel.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { cn } from "@/lib/utils/cn"; + +// ── Types ────────────────────────────────────────────────────────────────── + +type Platform = "x" | "linkedin" | "reddit"; +type Tone = "casual" | "professional" | "feedback-seeking" | "educational"; +type PrivacyLevel = "high" | "medium" | "low"; + +type Props = { + selectedPlatforms: Platform[]; + tone: Tone; + privacyLevel: PrivacyLevel; + onPlatformsChange: (platforms: Platform[]) => void; + onToneChange: (tone: string) => void; + onPrivacyChange: (level: string) => void; +}; + +// ── Config ───────────────────────────────────────────────────────────────── + +type PlatformCfg = { value: Platform; label: string; dot: string }; + +const PLATFORMS: PlatformCfg[] = [ + { value: "x", label: "X (Twitter)", dot: "bg-zinc-100" }, + { value: "linkedin", label: "LinkedIn", dot: "bg-blue-500" }, + { value: "reddit", label: "Reddit", dot: "bg-orange-500" }, +]; + +type ToneCfg = { value: Tone; label: string; desc: string }; + +const TONES: ToneCfg[] = [ + { + value: "casual", + label: "Casual", + desc: "First-person, conversational — sounds like a person, not a press release", + }, + { + value: "professional", + label: "Professional", + desc: "Reflective and measured — polished narrative without the hype", + }, + { + value: "feedback-seeking", + label: "Feedback-seeking", + desc: "Ends with a genuine question to invite discussion", + }, + { + value: "educational", + label: "Educational", + desc: "Centers the lesson — broadly useful beyond this specific project", + }, +]; + +type PrivacyCfg = { value: PrivacyLevel; label: string; desc: string; accent: string }; + +const PRIVACY_LEVELS: PrivacyCfg[] = [ + { + value: "high", + label: "High", + desc: "No code details or internal specifics", + accent: "border-emerald-500/50 bg-emerald-500/10 text-emerald-300", + }, + { + value: "medium", + label: "Medium", + desc: "Outcomes and behavior only", + accent: "border-amber-500/50 bg-amber-500/10 text-amber-300", + }, + { + value: "low", + label: "Low", + desc: "Full technical detail", + accent: "border-red-500/50 bg-red-500/10 text-red-300", + }, +]; + +// ── Section wrapper ───────────────────────────────────────────────────────── + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + <div className="space-y-2.5"> + <h3 className="text-xs font-semibold uppercase tracking-widest text-zinc-500"> + {title} + </h3> + {children} + </div> + ); +} + +// ── OptionsPanel ──────────────────────────────────────────────────────────── + +export function OptionsPanel({ + selectedPlatforms, + tone, + privacyLevel, + onPlatformsChange, + onToneChange, + onPrivacyChange, +}: Props) { + const platformSet = new Set(selectedPlatforms); + + function togglePlatform(value: Platform) { + if (platformSet.has(value)) { + onPlatformsChange(selectedPlatforms.filter((p) => p !== value)); + } else { + onPlatformsChange([...selectedPlatforms, value]); + } + } + + return ( + <div className="space-y-6"> + {/* Platforms */} + <Section title="Platforms"> + <div className="flex flex-wrap gap-2"> + {PLATFORMS.map(({ value, label, dot }) => { + const active = platformSet.has(value); + return ( + <button + key={value} + type="button" + onClick={() => togglePlatform(value)} + aria-pressed={active} + className={cn( + "flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors", + active + ? "border-violet-500/50 bg-violet-500/10 text-violet-300" + : "border-zinc-800 bg-zinc-900 text-zinc-400 hover:border-zinc-700 hover:text-zinc-300", + )} + > + <span className={cn("h-2 w-2 flex-shrink-0 rounded-full", dot)} /> + {label} + </button> + ); + })} + </div> + {selectedPlatforms.length === 0 && ( + <p className="text-[11px] text-amber-500/80"> + Select at least one platform to enable generation. + </p> + )} + </Section> + + {/* Tone */} + <Section title="Tone"> + <div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> + {TONES.map(({ value, label, desc }) => { + const active = tone === value; + return ( + <button + key={value} + type="button" + onClick={() => onToneChange(value)} + aria-pressed={active} + className={cn( + "flex flex-col items-start rounded-md border px-3 py-2.5 text-left transition-colors", + active + ? "border-violet-500/50 bg-violet-500/10 text-violet-300" + : "border-zinc-800 bg-zinc-900 text-zinc-400 hover:border-zinc-700 hover:text-zinc-300", + )} + > + <span className="text-sm font-medium">{label}</span> + <span + className={cn( + "mt-1 text-[11px] leading-relaxed", + active ? "text-violet-400/70" : "text-zinc-600", + )} + > + {desc} + </span> + </button> + ); + })} + </div> + </Section> + + {/* Privacy level */} + <Section title="Privacy level"> + <div className="grid grid-cols-3 gap-2"> + {PRIVACY_LEVELS.map(({ value, label, desc, accent }) => { + const active = privacyLevel === value; + return ( + <button + key={value} + type="button" + onClick={() => onPrivacyChange(value)} + aria-pressed={active} + className={cn( + "flex flex-col items-start rounded-md border px-3 py-2.5 text-left transition-colors", + active + ? accent + : "border-zinc-800 bg-zinc-900 text-zinc-400 hover:border-zinc-700 hover:text-zinc-300", + )} + > + <span className="text-sm font-medium">{label}</span> + <span + className={cn( + "mt-1 text-[11px] leading-relaxed", + active ? "opacity-70" : "text-zinc-600", + )} + > + {desc} + </span> + </button> + ); + })} + </div> + </Section> + </div> + ); +} diff --git a/src/components/postGenerator/ResultsPanel.tsx b/src/components/postGenerator/ResultsPanel.tsx new file mode 100644 index 0000000..37d6564 --- /dev/null +++ b/src/components/postGenerator/ResultsPanel.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, RefreshCw, Copy, Check } from "lucide-react"; +import { cn } from "@/lib/utils/cn"; + +// ── Types ────────────────────────────────────────────────────────────────── + +type Platform = "x" | "linkedin" | "reddit"; + +type PostResult = { + platform: Platform; + content: string; + characterCount: number; +}; + +type Props = { + posts: PostResult[]; + onRegenerate: (platform: Platform) => void; + regenerating: Platform[]; +}; + +// ── Platform display config ──────────────────────────────────────────────── + +type PlatformCfg = { + label: string; + charLimit: number | null; +}; + +const PLATFORM_CFG: Record<Platform, PlatformCfg> = { + x: { label: "X (Twitter)", charLimit: 280 }, + linkedin: { label: "LinkedIn", charLimit: null }, + reddit: { label: "Reddit", charLimit: null }, +}; + +// ── Auto-resize helper ───────────────────────────────────────────────────── + +function useAutoResize(value: string) { + const ref = useRef<HTMLTextAreaElement>(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [value]); + + return ref; +} + +// ── Copy button ──────────────────────────────────────────────────────────── + +function CopyButton({ getText }: { getText: () => string }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(getText()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard API not available (e.g. non-HTTPS) — silently ignore + } + } + + return ( + <button + type="button" + onClick={handleCopy} + title={copied ? "Copied!" : "Copy to clipboard"} + className={cn( + "flex items-center gap-1.5 rounded border px-2.5 py-1 text-[11px] font-medium transition-colors", + copied + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400" + : "border-zinc-700 bg-zinc-800/60 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200", + )} + > + {copied ? ( + <> + <Check className="h-3 w-3" /> + Copied! + </> + ) : ( + <> + <Copy className="h-3 w-3" /> + Copy + </> + )} + </button> + ); +} + +// ── Post card ────────────────────────────────────────────────────────────── + +type CardProps = { + post: PostResult; + isRegenerating: boolean; + onRegenerate: () => void; +}; + +function PostCard({ post, isRegenerating, onRegenerate }: CardProps) { + const { platform, content } = post; + const cfg = PLATFORM_CFG[platform]; + + const [text, setText] = useState(content); + const textareaRef = useAutoResize(text); + const charCount = text.length; + const overLimit = + cfg.charLimit !== null && charCount > cfg.charLimit; + + // Sync incoming content (e.g. after a regeneration) + useEffect(() => { + setText(content); + }, [content]); + + const getText = useCallback(() => text, [text]); + + return ( + <div className="rounded-xl border border-zinc-800 bg-zinc-900/40"> + {/* Card header */} + <div className="flex items-center justify-between gap-3 border-b border-zinc-800 px-4 py-3"> + <span className="text-sm font-medium text-zinc-200">{cfg.label}</span> + + <div className="flex items-center gap-2"> + <CopyButton getText={getText} /> + + <button + type="button" + onClick={onRegenerate} + disabled={isRegenerating} + title="Regenerate" + className={cn( + "flex items-center gap-1.5 rounded border px-2.5 py-1 text-[11px] font-medium transition-colors", + isRegenerating + ? "cursor-not-allowed border-zinc-800 text-zinc-600" + : "border-zinc-700 bg-zinc-800/60 text-zinc-400 hover:border-violet-500/50 hover:text-violet-300", + )} + > + {isRegenerating ? ( + <Loader2 className="h-3 w-3 animate-spin" /> + ) : ( + <RefreshCw className="h-3 w-3" /> + )} + {isRegenerating ? "Regenerating…" : "Regenerate"} + </button> + </div> + </div> + + {/* Textarea */} + <div className="px-4 pb-4 pt-3"> + <textarea + ref={textareaRef} + value={text} + onChange={(e) => setText(e.target.value)} + rows={4} + className="w-full resize-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2.5 text-sm leading-relaxed text-zinc-200 placeholder-zinc-700 outline-none transition-colors focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/20" + /> + + {/* Character counter */} + <div className="mt-1.5 flex justify-end"> + <span + className={cn( + "font-mono text-[11px] tabular-nums", + overLimit + ? "text-red-400" + : cfg.charLimit !== null && charCount > cfg.charLimit * 0.9 + ? "text-amber-400" + : "text-zinc-600", + )} + > + {cfg.charLimit !== null + ? `${charCount} / ${cfg.charLimit}` + : charCount} + </span> + </div> + </div> + </div> + ); +} + +// ── ResultsPanel ─────────────────────────────────────────────────────────── + +export function ResultsPanel({ posts, onRegenerate, regenerating }: Props) { + const regeneratingSet = new Set(regenerating); + + if (posts.length === 0) return null; + + return ( + <div className="space-y-4"> + {posts.map((post) => ( + <PostCard + key={post.platform} + post={post} + isRegenerating={regeneratingSet.has(post.platform)} + onRegenerate={() => onRegenerate(post.platform)} + /> + ))} + </div> + ); +} diff --git a/src/components/postGenerator/loading/GeneratingOverlay.tsx b/src/components/postGenerator/loading/GeneratingOverlay.tsx new file mode 100644 index 0000000..23ba917 --- /dev/null +++ b/src/components/postGenerator/loading/GeneratingOverlay.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import PulsingOrb from "./PulsingOrb"; +import PipelineSteps from "./PipelineSteps"; +import ParticleBackground from "./ParticleBackground"; + +type Props = { + isVisible: boolean; + onComplete?: () => void; +}; + +const STAGE_DURATIONS = [2200, 2200] as const; // ms between stage 0→1 and 1→2 +const FADE_OUT_MS = 500; + +const PROGRESS_BY_STAGE: Record<number, string> = { + 0: "35%", + 1: "70%", + 2: "95%", +}; + +const ORB_STAGES = ["enriching", "generating", "done"] as const satisfies readonly ( + | "idle" + | "enriching" + | "generating" + | "done" +)[]; + +export default function GeneratingOverlay({ isVisible, onComplete }: Props) { + const [stage, setStage] = useState(0); + const [opacity, setOpacity] = useState(0); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + // Fade in / out based on isVisible + useEffect(() => { + if (isVisible) { + // Next tick so the initial opacity:0 is painted first + const t = requestAnimationFrame(() => setOpacity(1)); + return () => cancelAnimationFrame(t); + } else { + setOpacity(0); + const t = setTimeout(() => { + onCompleteRef.current?.(); + }, FADE_OUT_MS); + return () => clearTimeout(t); + } + }, [isVisible]); + + // Reset + advance stage progression whenever overlay becomes visible + useEffect(() => { + if (!isVisible) { + setStage(0); + return; + } + + setStage(0); + + const t1 = setTimeout(() => setStage(1), STAGE_DURATIONS[0]); + const t2 = setTimeout(() => setStage(2), STAGE_DURATIONS[0] + STAGE_DURATIONS[1]); + + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + }, [isVisible]); + + const progressWidth = PROGRESS_BY_STAGE[stage] ?? "0%"; + const orbStage = ORB_STAGES[stage]; + + return ( + <div + aria-live="polite" + aria-label="Generating posts" + style={{ + position: "fixed", + inset: 0, + zIndex: 50, + background: "rgba(13, 13, 18, 0.97)", + opacity, + transition: isVisible + ? "opacity 400ms ease" + : `opacity ${FADE_OUT_MS}ms ease`, + pointerEvents: isVisible ? "auto" : "none", + }} + > + <style>{` + @keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } + } + `}</style> + + {/* Particle background fills the entire overlay */} + <ParticleBackground /> + + {/* Progress bar */} + <div + style={{ + position: "absolute", + top: 0, + left: 0, + height: 2, + width: progressWidth, + background: "#7C3AED", + transition: "width 1.8s cubic-bezier(0.4, 0, 0.2, 1)", + zIndex: 1, + }} + /> + + {/* Main centered content */} + <div + style={{ position: "relative", zIndex: 1 }} + className="flex h-full flex-col items-center justify-center gap-8 px-6" + > + {/* Top label */} + <p className="font-mono text-xs tracking-widest text-zinc-500"> + devlog · generating + <span + style={{ animation: "blink 1s step-end infinite" }} + className="ml-0.5" + > + ▋ + </span> + </p> + + {/* Orb */} + <PulsingOrb stage={orbStage} /> + + {/* Pipeline steps */} + <div style={{ width: "100%", maxWidth: 320 }}> + <PipelineSteps activeStage={stage} /> + </div> + + {/* Bottom hint */} + <p className="text-center font-mono text-[11px] text-zinc-700"> + This usually takes 5–10 seconds + </p> + </div> + </div> + ); +} diff --git a/src/components/postGenerator/loading/ParticleBackground.tsx b/src/components/postGenerator/loading/ParticleBackground.tsx new file mode 100644 index 0000000..51aa74b --- /dev/null +++ b/src/components/postGenerator/loading/ParticleBackground.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +const PARTICLE_COUNT = 20; +const PARTICLE_COLOR = "124, 58, 237"; // #7C3AED as RGB components +const CONNECTION_DISTANCE = 120; +const CONNECTION_OPACITY = 0.12; +const MIN_RADIUS = 2; +const MAX_RADIUS = 4; +const MIN_SPEED = 0.15; +const MAX_SPEED = 0.30; +const OPACITIES = [0.4, 0.7, 1.0]; + +type Particle = { + x: number; + y: number; + vx: number; + vy: number; + radius: number; + opacity: number; +}; + +function rand(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function initParticles(width: number, height: number): Particle[] { + return Array.from({ length: PARTICLE_COUNT }, () => { + const speed = rand(MIN_SPEED, MAX_SPEED); + const angle = Math.random() * Math.PI * 2; + return { + x: rand(0, width), + y: rand(0, height), + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + radius: rand(MIN_RADIUS, MAX_RADIUS), + opacity: OPACITIES[Math.floor(Math.random() * OPACITIES.length)], + }; + }); +} + +export default function ParticleBackground() { + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let rafId: number; + let particles: Particle[] = []; + + function setSize() { + const parent = canvas!.parentElement; + if (!parent) return; + const { width, height } = parent.getBoundingClientRect(); + canvas!.width = width; + canvas!.height = height; + } + + function draw() { + const w = canvas!.width; + const h = canvas!.height; + + ctx!.clearRect(0, 0, w, h); + + // Update and draw particles + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + + // Bounce off edges + if (p.x - p.radius < 0) { p.x = p.radius; p.vx = Math.abs(p.vx); } + if (p.x + p.radius > w) { p.x = w - p.radius; p.vx = -Math.abs(p.vx); } + if (p.y - p.radius < 0) { p.y = p.radius; p.vy = Math.abs(p.vy); } + if (p.y + p.radius > h) { p.y = h - p.radius; p.vy = -Math.abs(p.vy); } + } + + // Draw connections + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const a = particles[i]; + const b = particles[j]; + const dx = a.x - b.x; + const dy = a.y - b.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < CONNECTION_DISTANCE) { + // Fade line as distance increases + const alpha = CONNECTION_OPACITY * (1 - dist / CONNECTION_DISTANCE); + ctx!.beginPath(); + ctx!.moveTo(a.x, a.y); + ctx!.lineTo(b.x, b.y); + ctx!.strokeStyle = `rgba(${PARTICLE_COLOR}, ${alpha})`; + ctx!.lineWidth = 1; + ctx!.stroke(); + } + } + } + + // Draw particles on top of lines + for (const p of particles) { + ctx!.beginPath(); + ctx!.arc(p.x, p.y, p.radius, 0, Math.PI * 2); + ctx!.fillStyle = `rgba(${PARTICLE_COLOR}, ${p.opacity})`; + ctx!.fill(); + } + + rafId = requestAnimationFrame(draw); + } + + // Init + setSize(); + particles = initParticles(canvas.width, canvas.height); + + // Handle container resize + const observer = new ResizeObserver(() => { + setSize(); + // Re-clamp all particles to the new bounds + for (const p of particles) { + p.x = Math.min(p.x, canvas!.width - p.radius); + p.y = Math.min(p.y, canvas!.height - p.radius); + } + }); + if (canvas.parentElement) observer.observe(canvas.parentElement); + + rafId = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + }; + }, []); + + return ( + <canvas + ref={canvasRef} + style={{ + position: "absolute", + inset: 0, + zIndex: 0, + display: "block", + pointerEvents: "none", + }} + /> + ); +} diff --git a/src/components/postGenerator/loading/PipelineSteps.tsx b/src/components/postGenerator/loading/PipelineSteps.tsx new file mode 100644 index 0000000..695c92f --- /dev/null +++ b/src/components/postGenerator/loading/PipelineSteps.tsx @@ -0,0 +1,125 @@ +"use client"; + +type Props = { + activeStage: number; // 0, 1, or 2 +}; + +const STEPS = [ + { + id: "enriching", + icon: "→", + label: "commit detected", + detail: "Reading your GitHub activity...", + color: "#f59e0b", + }, + { + id: "generating", + icon: "⚡", + label: "AI analyzing", + detail: "Crafting platform-optimized drafts...", + color: "#7C3AED", + }, + { + id: "done", + icon: "◎", + label: "posts generated", + detail: "Your content is ready.", + color: "#10b981", + }, +]; + +export default function PipelineSteps({ activeStage }: Props) { + return ( + <div className="flex flex-col"> + <style>{` + @keyframes step-in { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } + } + @keyframes typewriter { + from { width: 0; } + to { width: 100%; } + } + `}</style> + + {STEPS.map((step, index) => { + const isPast = activeStage > index; + const isActive = activeStage === index; + const isFuture = activeStage < index; + + return ( + <div + key={step.id} + style={{ + opacity: isFuture ? 0.3 : 1, + animation: `step-in 0.4s ease forwards`, + animationDelay: `${index * 150}ms`, + animationFillMode: "both", + transition: "opacity 0.3s ease", + }} + className="relative flex items-start gap-3 pb-6 last:pb-0" + > + {/* Vertical connector line */} + {index < STEPS.length - 1 && ( + <div + className="absolute left-[9px] top-5 w-px" + style={{ + height: "calc(100% - 8px)", + backgroundColor: isPast ? step.color : "rgba(63,63,70,0.6)", + transition: "background-color 0.4s ease", + }} + /> + )} + + {/* Dot / icon */} + <div + className="relative z-10 mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-bold" + style={{ + backgroundColor: isActive || isPast + ? step.color + : "rgba(63,63,70,0.8)", + color: isActive || isPast ? "#fff" : "rgba(161,161,170,0.6)", + boxShadow: isActive + ? `0 0 10px 2px ${step.color}66` + : "none", + transition: "background-color 0.3s ease, box-shadow 0.3s ease", + }} + > + {step.icon} + </div> + + {/* Text */} + <div className="min-w-0 flex-1 pt-0.5"> + <p + className="font-mono text-sm font-medium leading-none" + style={{ + color: isActive || isPast ? step.color : "rgba(113,113,122,0.7)", + transition: "color 0.3s ease", + }} + > + {step.label} + </p> + + {/* Detail — visible only for active and past steps */} + {(isActive || isPast) && ( + <span + className="mt-1 block text-xs text-zinc-500" + style={{ + overflow: "hidden", + whiteSpace: "nowrap", + width: isPast ? "100%" : undefined, + animation: isActive + ? "typewriter 0.8s steps(40) forwards" + : undefined, + }} + > + {step.detail} + </span> + )} + </div> + </div> + ); + })} + </div> + ); +} diff --git a/src/components/postGenerator/loading/PulsingOrb.tsx b/src/components/postGenerator/loading/PulsingOrb.tsx new file mode 100644 index 0000000..999dfc3 --- /dev/null +++ b/src/components/postGenerator/loading/PulsingOrb.tsx @@ -0,0 +1,66 @@ +"use client"; + +type Props = { + stage: "idle" | "enriching" | "generating" | "done"; +}; + +export default function PulsingOrb({ stage }: Props) { + const isActive = stage !== "idle"; + + // Flash to a brighter violet when stage changes + const innerColor = isActive ? "#a78bfa" : "#7C3AED"; + + return ( + <div + style={{ width: 200, height: 200 }} + className="relative flex items-center justify-center" + > + <style>{` + @keyframes orb-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.08); opacity: 0.85; } + } + @keyframes ring-pulse { + 0%, 100% { transform: scale(1); opacity: 0.15; } + 50% { transform: scale(1.15); opacity: 0.05; } + } + `}</style> + + {/* Outer ring */} + <div + className="absolute rounded-full" + style={{ + width: 200, + height: 200, + backgroundColor: "rgba(124, 58, 237, 0.10)", + animation: "ring-pulse 3s ease-in-out infinite", + }} + /> + + {/* Middle ring */} + <div + className="absolute rounded-full" + style={{ + width: 140, + height: 140, + backgroundColor: "rgba(124, 58, 237, 0.20)", + animation: "ring-pulse 2s ease-in-out infinite", + }} + /> + + {/* Inner orb */} + <div + className="absolute rounded-full" + style={{ + width: 80, + height: 80, + backgroundColor: innerColor, + transition: "background-color 300ms ease", + boxShadow: + "0 0 40px 8px rgba(124, 58, 237, 0.6), 0 0 80px 20px rgba(124, 58, 237, 0.2)", + animation: "orb-pulse 2s ease-in-out infinite", + }} + /> + </div> + ); +} diff --git a/src/components/providers/AppProviders.tsx b/src/components/providers/AppProviders.tsx new file mode 100644 index 0000000..7ad11a1 --- /dev/null +++ b/src/components/providers/AppProviders.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +export function AppProviders({ children }: { children: React.ReactNode }) { + return <SessionProvider>{children}</SessionProvider>; +} diff --git a/src/components/timeline/ConstellationLayer.tsx b/src/components/timeline/ConstellationLayer.tsx new file mode 100644 index 0000000..6967036 --- /dev/null +++ b/src/components/timeline/ConstellationLayer.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import type { ConstellationStar, StarCluster } from "@/lib/timeline/constellationUtils"; +import { + getStarRadius, + toConstellationEventType, +} from "@/lib/timeline/constellationUtils"; + +type Props = { + stars: ConstellationStar[]; + clusters: StarCluster[]; + selectedStarId: string | null; + hoveredStarId: string | null; + onStarHover: (id: string | null) => void; + onStarClick: (id: string) => void; + width: number; + height: number; +}; + +const toPixel = (star: ConstellationStar, width: number, height: number) => ({ + px: (star.x / 100) * width, + py: (star.y / 100) * height, +}); + +function buildClusterTrailPathSorted( + sortedStars: ConstellationStar[], + width: number, + height: number, +): string { + const pts = sortedStars.map((s) => toPixel(s, width, height)); + if (pts.length < 2) return ""; + + let d = `M ${pts[0].px} ${pts[0].py}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i]; + const p1 = pts[i + 1]; + const p2 = pts[i + 2]; + const c1x = (p0.px + p1.px) / 2; + const c1y = (p0.py + p1.py) / 2; + const c2x = p2 ? (p1.px + p2.px) / 2 : c1x; + const c2y = p2 ? (p1.py + p2.py) / 2 : c1y; + d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p1.px} ${p1.py}`; + } + return d; +} + +function gradientFillUrl(entryType: ReturnType<typeof toConstellationEventType>): string { + return `url(#star-glow-${entryType})`; +} + +type Ripple = { key: number; px: number; py: number }; + +const STAR_TRANSITION = "all 0.25s cubic-bezier(0.4, 0, 0.2, 1)"; +const DIM_OPACITY = 0.25; + +export default function ConstellationLayer({ + stars, + clusters, + selectedStarId, + hoveredStarId, + onStarHover, + onStarClick, + width, + height, +}: Props) { + const [ripples, setRipples] = useState<Ripple[]>([]); + const rippleKeyRef = useRef(0); + + const triggerRipple = useCallback((px: number, py: number) => { + const key = ++rippleKeyRef.current; + setRipples((prev) => [...prev, { key, px, py }]); + window.setTimeout(() => { + setRipples((prev) => prev.filter((r) => r.key !== key)); + }, 800); + }, []); + + const handleStarClick = useCallback( + (id: string, px: number, py: number) => { + triggerRipple(px, py); + onStarClick(id); + }, + [onStarClick, triggerRipple], + ); + + if (width <= 0 || height <= 0) { + return null; + } + + return ( + <svg + width={width} + height={height} + className="relative z-[1] block overflow-visible" + style={{ pointerEvents: "auto" }} + > + <defs> + <radialGradient id="star-glow-commit" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stopColor="white" stopOpacity="0.9" /> + <stop offset="40%" stopColor="#c4b5fd" stopOpacity="0.8" /> + <stop offset="100%" stopColor="#c4b5fd" stopOpacity="0" /> + </radialGradient> + <radialGradient id="star-glow-pull_request" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stopColor="white" stopOpacity="0.9" /> + <stop offset="40%" stopColor="#a78bfa" stopOpacity="0.8" /> + <stop offset="100%" stopColor="#a78bfa" stopOpacity="0" /> + </radialGradient> + <radialGradient id="star-glow-release" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stopColor="white" stopOpacity="0.95" /> + <stop offset="35%" stopColor="#fbbf24" stopOpacity="0.85" /> + <stop offset="100%" stopColor="#fbbf24" stopOpacity="0" /> + </radialGradient> + <radialGradient id="star-glow-milestone" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stopColor="white" stopOpacity="0.9" /> + <stop offset="40%" stopColor="#34d399" stopOpacity="0.8" /> + <stop offset="100%" stopColor="#34d399" stopOpacity="0" /> + </radialGradient> + + <filter id="constellation-selected-glow" x="-80%" y="-80%" width="260%" height="260%"> + <feGaussianBlur stdDeviation="2.5" result="blur" /> + <feMerge> + <feMergeNode in="blur" /> + <feMergeNode in="SourceGraphic" /> + </feMerge> + </filter> + + <style> + {` + @keyframes trail-flow { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -48; } + } + @keyframes mid-glow-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.3); } + } + @keyframes click-ripple { + from { + transform: scale(1); + opacity: 0.55; + } + to { + transform: scale(15); + opacity: 0; + } + } + `} + </style> + </defs> + + {/* Trails */} + <g style={{ pointerEvents: "none" }}> + {clusters.map((cluster) => { + if (cluster.stars.length < 2) return null; + const sorted = [...cluster.stars].sort((a, b) => a.x - b.x); + const d = buildClusterTrailPathSorted(sorted, width, height); + if (!d) return null; + const trailColor = sorted[0]?.color.trail ?? "rgba(148,163,184,0.1)"; + return ( + <path + key={`trail-${cluster.id}`} + d={d} + fill="none" + stroke={trailColor} + strokeWidth={1} + strokeOpacity={0.4} + strokeLinecap="round" + strokeLinejoin="round" + strokeDasharray="4 8" + style={{ + animation: "trail-flow 4s linear infinite", + }} + /> + ); + })} + </g> + + {/* Cluster labels */} + <g style={{ pointerEvents: "none" }}> + {clusters.map((cluster) => { + const ys = cluster.stars.map((s) => toPixel(s, width, height).py); + const minY = ys.length ? Math.min(...ys) : 0; + const cx = (cluster.centerX / 100) * width; + const ty = minY - 20; + return ( + <text + key={`label-${cluster.id}`} + x={cx} + y={ty} + textAnchor="middle" + fill="rgba(255,255,255,0.2)" + fontSize={11} + fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" + letterSpacing="0.05em" + > + {cluster.label} + <tspan fill="rgba(124,58,237,0.6)" dx={6}> + {cluster.stars.length}★ + </tspan> + </text> + ); + })} + </g> + + {/* Stars */} + <g> + {stars.map((star, starIndex) => { + const { px, py } = toPixel(star, width, height); + const id = star.entry.id; + const isHovered = hoveredStarId === id; + const isSelected = selectedStarId === id; + const dimOthers = selectedStarId !== null && !isSelected; + const baseR = getStarRadius(star.size); + + const outerBaseR = baseR * 3.5; + const outerR = outerBaseR + (isHovered ? 4 : 0); + let outerOpacity = 0.15; + if (isSelected) outerOpacity = 0.5; + else if (isHovered) outerOpacity = 0.35; + + const midR = baseR * 2; + const coreR = + baseR + (isHovered ? 2 : 0) + (isSelected ? 3 : 0); + + const hitR = Math.max(24, baseR * 4); + + const entryType = toConstellationEventType(star.entry); + const fillUrl = gradientFillUrl(entryType); + + const groupOpacity = dimOthers ? DIM_OPACITY : 1; + + return ( + <g + key={id} + style={{ + opacity: groupOpacity, + transition: "opacity 0.4s ease", + }} + > + {/* Outer glow */} + <circle + cx={px} + cy={py} + r={outerR} + fill={star.color.glow} + fillOpacity={outerOpacity} + style={{ transition: STAR_TRANSITION }} + /> + + {/* Mid glow pulse */} + <g transform={`translate(${px}, ${py})`}> + <g + style={{ + transformBox: "fill-box", + transformOrigin: "center", + animation: `mid-glow-pulse 3s ease-in-out infinite`, + animationDelay: `${(starIndex % 12) * 0.15}s`, + }} + > + <circle + cx={0} + cy={0} + r={midR} + fill={star.color.glow} + fillOpacity={0.3} + /> + </g> + </g> + + {/* Core */} + <circle + cx={px} + cy={py} + r={coreR} + fill={fillUrl} + opacity={isHovered ? 1 : 0.92} + stroke={ + isSelected + ? "rgba(196,181,253,0.85)" + : isHovered + ? "rgba(255,255,255,0.45)" + : "none" + } + strokeWidth={isSelected ? 2 : isHovered ? 1.5 : 0} + filter={isSelected ? "url(#constellation-selected-glow)" : undefined} + style={{ transition: STAR_TRANSITION }} + /> + + {/* Hit target */} + <circle + cx={px} + cy={py} + r={hitR} + fill="transparent" + style={{ cursor: "pointer", transition: STAR_TRANSITION }} + onMouseEnter={() => onStarHover(id)} + onMouseLeave={() => onStarHover(null)} + onClick={(e) => { + e.stopPropagation(); + handleStarClick(id, px, py); + }} + /> + </g> + ); + })} + </g> + + {/* Ripples */} + <g style={{ pointerEvents: "none" }}> + {ripples.map((r) => ( + <g key={r.key} transform={`translate(${r.px}, ${r.py})`}> + <circle + r={4} + fill="rgba(167,139,250,0.35)" + stroke="rgba(255,255,255,0.35)" + strokeWidth={1} + style={{ + animation: "click-ripple 800ms cubic-bezier(0.4, 0, 0.2, 1) forwards", + transformBox: "fill-box", + transformOrigin: "center", + }} + /> + </g> + ))} + </g> + </svg> + ); +} diff --git a/src/components/timeline/ConstellationTimeline.tsx b/src/components/timeline/ConstellationTimeline.tsx new file mode 100644 index 0000000..5e37f9f --- /dev/null +++ b/src/components/timeline/ConstellationTimeline.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { TimelineEntry } from "@/features/timeline/types"; +import { buildConstellationStars } from "@/lib/timeline/constellationUtils"; +import ConstellationLayer from "./ConstellationLayer"; +import StarDetailCard from "./StarDetailCard"; +import StarFieldBackground from "./StarFieldBackground"; + +const CONSTELLATION_H = 480; + +type Props = { + entries: TimelineEntry[]; +}; + +export default function ConstellationTimeline({ entries }: Props) { + const [hoveredStarId, setHoveredStarId] = useState<string | null>(null); + const [selectedStarId, setSelectedStarId] = useState<string | null>(null); + const [dimensions, setDimensions] = useState({ width: 0, height: CONSTELLATION_H }); + const containerRef = useRef<HTMLDivElement>(null); + + const { stars, clusters } = useMemo( + () => buildConstellationStars(entries), + [entries], + ); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const measure = () => { + const w = Math.floor(el.clientWidth); + setDimensions((d) => + w !== d.width ? { ...d, width: w } : d, + ); + }; + + measure(); + const ro = new ResizeObserver(() => measure()); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const handleGeneratePost = (entryId: string) => { + window.location.href = `/generate?eventId=${encodeURIComponent(entryId)}`; + }; + + const commitCount = entries.filter((e) => e.type === "commit").length; + const prCount = entries.filter((e) => e.type === "pr").length; + const releaseCount = entries.filter((e) => e.type === "release").length; + + const activeCardId = selectedStarId || hoveredStarId; + const cardStar = + activeCardId == null + ? null + : (stars.find((s) => s.entry.id === activeCardId) ?? null); + + return ( + <div + ref={containerRef} + role="presentation" + onClick={() => setSelectedStarId(null)} + style={{ + position: "relative", + width: "100%", + minHeight: 560, + background: "#080810", + borderRadius: 16, + overflow: "hidden", + border: "1px solid rgba(255,255,255,0.06)", + }} + > + {/* Stats bar */} + <div + style={{ + display: "flex", + gap: 24, + padding: "12px 20px", + borderBottom: "1px solid rgba(255,255,255,0.05)", + background: "rgba(0,0,0,0.2)", + }} + > + {[ + { + label: "commits", + value: commitCount, + color: "#c4b5fd", + }, + { + label: "PRs", + value: prCount, + color: "#a78bfa", + }, + { + label: "releases", + value: releaseCount, + color: "#fbbf24", + }, + { + label: "clusters", + value: clusters.length, + color: "#34d399", + }, + ].map((stat) => ( + <div + key={stat.label} + style={{ display: "flex", alignItems: "baseline", gap: 6 }} + > + <span + style={{ + color: stat.color, + fontSize: 20, + fontWeight: 700, + }} + > + {stat.value} + </span> + <span + style={{ + color: "rgba(255,255,255,0.3)", + fontSize: 11, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + }} + > + {stat.label} + </span> + </div> + ))} + <div + style={{ + marginLeft: "auto", + color: "rgba(255,255,255,0.2)", + fontSize: 11, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + alignSelf: "center", + }} + > + click any star to explore + </div> + </div> + + {/* Constellation canvas */} + <div + style={{ + position: "relative", + width: "100%", + height: CONSTELLATION_H, + }} + > + {entries.length === 0 ? ( + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: CONSTELLATION_H, + gap: 16, + }} + > + <div style={{ fontSize: 48, opacity: 0.2 }}>✦</div> + <p + style={{ + color: "rgba(255,255,255,0.25)", + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: 13, + }} + > + No events yet. Connect a GitHub repo in Settings. + </p> + </div> + ) : ( + <> + <StarFieldBackground /> + + <div + style={{ + position: "absolute", + inset: 0, + zIndex: 1, + }} + > + <ConstellationLayer + stars={stars} + clusters={clusters} + selectedStarId={selectedStarId} + hoveredStarId={hoveredStarId} + onStarHover={setHoveredStarId} + onStarClick={(id) => + setSelectedStarId((prev) => (prev === id ? null : id)) + } + width={dimensions.width} + height={CONSTELLATION_H} + /> + </div> + + {(hoveredStarId || selectedStarId) && ( + <StarDetailCard + star={cardStar} + isLocked={!!selectedStarId} + containerWidth={dimensions.width} + containerHeight={dimensions.height} + onClose={() => setSelectedStarId(null)} + onGeneratePost={handleGeneratePost} + /> + )} + </> + )} + </div> + + {/* Legend */} + <div + style={{ + display: "flex", + gap: 16, + padding: "10px 20px", + borderTop: "1px solid rgba(255,255,255,0.05)", + background: "rgba(0,0,0,0.2)", + flexWrap: "wrap", + }} + > + {[ + { label: "commit", color: "#c4b5fd" }, + { label: "pull request", color: "#a78bfa" }, + { label: "release", color: "#fbbf24" }, + { label: "milestone", color: "#34d399" }, + ].map((item) => ( + <div + key={item.label} + style={{ display: "flex", alignItems: "center", gap: 6 }} + > + <div + style={{ + width: 8, + height: 8, + borderRadius: "50%", + background: item.color, + boxShadow: `0 0 6px ${item.color}`, + }} + /> + <span + style={{ + color: "rgba(255,255,255,0.3)", + fontSize: 11, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + }} + > + {item.label} + </span> + </div> + ))} + </div> + </div> + ); +} diff --git a/src/components/timeline/StarDetailCard.tsx b/src/components/timeline/StarDetailCard.tsx new file mode 100644 index 0000000..37abc28 --- /dev/null +++ b/src/components/timeline/StarDetailCard.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { TimelineEntry } from "@/features/timeline/types"; +import type { ConstellationStar } from "@/lib/timeline/constellationUtils"; + +type Props = { + star: ConstellationStar | null; + isLocked: boolean; + containerWidth: number; + containerHeight: number; + onClose: () => void; + onGeneratePost: (entryId: string) => void; +}; + +const CARD_WIDTH = 280; + +const OPACITY_TRANSFORM_TRANSITION = + "opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)"; + +function badgeTypeFromEntry(entry: TimelineEntry): string { + if (entry.type === "pr") return "pull_request"; + return entry.type; +} + +function EventTypeBadge({ type }: { type: string }) { + const map: Record<string, { label: string; color: string; bg: string }> = { + commit: { label: "commit", color: "#c4b5fd", bg: "rgba(196,181,253,0.1)" }, + pull_request: { label: "PR", color: "#a78bfa", bg: "rgba(167,139,250,0.15)" }, + release: { label: "release", color: "#fbbf24", bg: "rgba(251,191,36,0.1)" }, + milestone: { + label: "milestone", + color: "#34d399", + bg: "rgba(52,211,153,0.1)", + }, + }; + const style = map[type] ?? { + label: type, + color: "#94a3b8", + bg: "rgba(148,163,184,0.1)", + }; + + return ( + <span + style={{ + background: style.bg, + color: style.color, + border: `1px solid ${style.color}30`, + borderRadius: 4, + padding: "1px 6px", + fontSize: 10, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.05em", + }} + > + {style.label} + </span> + ); +} + +export default function StarDetailCard({ + star, + isLocked, + containerWidth, + containerHeight, + onClose, + onGeneratePost, +}: Props) { + const [enterReady, setEnterReady] = useState(false); + + const activeEntryId = star?.entry.id; + + useEffect(() => { + if (!activeEntryId) { + setEnterReady(false); + return; + } + setEnterReady(false); + let innerFrame = 0; + const outerFrame = window.requestAnimationFrame(() => { + innerFrame = window.requestAnimationFrame(() => setEnterReady(true)); + }); + return () => { + window.cancelAnimationFrame(outerFrame); + window.cancelAnimationFrame(innerFrame); + }; + }, [activeEntryId]); + + if (!star || containerWidth <= 0 || containerHeight <= 0) return null; + + const badgeType = badgeTypeFromEntry(star.entry); + const dateSrc = star.entry.dateIso; + + const cardX = Math.max( + 8, + Math.min( + containerWidth - CARD_WIDTH - 8, + (star.x / 100) * containerWidth - CARD_WIDTH / 2, + ), + ); + const cardY = Math.max( + 8, + (star.y / 100) * containerHeight - (isLocked ? 220 : 120) - 16, + ); + + const revealStyle = { + opacity: enterReady ? 1 : 0, + transform: enterReady ? "scale(1)" : "scale(0.92)", + transformOrigin: "center bottom", + } as const; + + if (!isLocked) { + return ( + <div + style={{ + position: "absolute", + left: cardX, + top: cardY, + width: CARD_WIDTH, + background: "rgba(13,13,24,0.92)", + border: "1px solid rgba(167,139,250,0.3)", + borderRadius: 12, + padding: "12px 14px", + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + boxShadow: "0 8px 32px rgba(0,0,0,0.4)", + transition: OPACITY_TRANSFORM_TRANSITION, + pointerEvents: "none", + ...revealStyle, + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 6, + }} + > + <EventTypeBadge type={badgeType} /> + <span + style={{ + color: "rgba(255,255,255,0.4)", + fontSize: 11, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + }} + > + {new Date(dateSrc).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + </span> + </div> + <p + style={{ + color: "white", + fontSize: 13, + fontWeight: 500, + margin: 0, + }} + > + {star.entry.title} + </p> + </div> + ); + } + + return ( + <div + onClick={(e) => e.stopPropagation()} + style={{ + position: "absolute", + left: cardX, + top: cardY, + width: CARD_WIDTH, + background: "rgba(13,13,24,0.96)", + border: "1px solid rgba(167,139,250,0.4)", + borderRadius: 14, + padding: "16px", + backdropFilter: "blur(16px)", + WebkitBackdropFilter: "blur(16px)", + boxShadow: + "0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(124,58,237,0.1)", + zIndex: 10, + transition: OPACITY_TRANSFORM_TRANSITION, + ...revealStyle, + }} + > + <button + type="button" + onClick={onClose} + aria-label="Close" + style={{ + position: "absolute", + top: 10, + right: 10, + background: "none", + border: "none", + color: "rgba(255,255,255,0.3)", + cursor: "pointer", + fontSize: 16, + lineHeight: 1, + }} + > + ✕ + </button> + + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 10, + paddingRight: 28, + }} + > + <EventTypeBadge type={badgeType} /> + <span + style={{ + color: "rgba(255,255,255,0.35)", + fontSize: 11, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + }} + > + {new Date(dateSrc).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + })} + </span> + </div> + + <p + style={{ + color: "white", + fontSize: 14, + fontWeight: 600, + margin: "0 0 8px", + }} + > + {star.entry.title} + </p> + + <p + style={{ + color: "rgba(255,255,255,0.5)", + fontSize: 12, + lineHeight: 1.6, + margin: "0 0 14px", + display: "-webkit-box", + WebkitLineClamp: 3, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }} + > + {star.entry.summary} + </p> + + <p + style={{ + color: "rgba(124,58,237,0.7)", + fontSize: 11, + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + margin: "0 0 14px", + }} + > + {star.entry.repo} + </p> + + <button + type="button" + onClick={() => onGeneratePost(star.entry.id)} + style={{ + width: "100%", + padding: "8px 0", + borderRadius: 8, + background: + "linear-gradient(135deg, rgba(124,58,237,0.3), rgba(124,58,237,0.15))", + border: "1px solid rgba(124,58,237,0.4)", + color: "#a78bfa", + fontSize: 12, + fontWeight: 600, + cursor: "pointer", + transition: "all 0.2s ease", + }} + > + ✦ Generate post from this event + </button> + </div> + ); +} diff --git a/src/components/timeline/StarFieldBackground.tsx b/src/components/timeline/StarFieldBackground.tsx new file mode 100644 index 0000000..175fec4 --- /dev/null +++ b/src/components/timeline/StarFieldBackground.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type BgStar = { + x: number; + y: number; + radius: number; + opacity: number; + speed: number; + angle: number; + twinkleOffset: number; +}; + +function randomBetween(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function createBgStars(width: number, height: number): BgStar[] { + const stars: BgStar[] = []; + for (let i = 0; i < 180; i++) { + stars.push({ + x: Math.random() * width, + y: Math.random() * height, + radius: randomBetween(0.3, 1.2), + opacity: randomBetween(0.1, 0.5), + speed: randomBetween(0.02, 0.08), + angle: randomBetween(0, Math.PI * 2), + twinkleOffset: randomBetween(0, Math.PI * 2), + }); + } + return stars; +} + +export default function StarFieldBackground() { + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const starsRef = useRef<BgStar[]>([]); + const rafRef = useRef<number>(0); + const startRef = useRef<number>(0); + const lastSizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = Math.min(window.devicePixelRatio ?? 1, 2); + + const resize = () => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) return; + + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const { w: lw, h: lh } = lastSizeRef.current; + if (starsRef.current.length === 0 || lw !== w || lh !== h) { + starsRef.current = createBgStars(w, h); + lastSizeRef.current = { w, h }; + } + }; + + const ro = new ResizeObserver(() => { + resize(); + }); + ro.observe(container); + resize(); + + startRef.current = performance.now(); + + const tick = (now: number) => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) { + rafRef.current = requestAnimationFrame(tick); + return; + } + + const elapsedSec = (now - startRef.current) / 1000; + const stars = starsRef.current; + + ctx.fillStyle = "#080810"; + ctx.fillRect(0, 0, w, h); + + for (const s of stars) { + s.x += Math.cos(s.angle) * s.speed; + s.y += Math.sin(s.angle) * s.speed; + + if (s.x < 0) s.x += w; + else if (s.x >= w) s.x -= w; + if (s.y < 0) s.y += h; + else if (s.y >= h) s.y -= h; + + const twinkle = + s.opacity + + Math.sin(elapsedSec * 0.8 + s.twinkleOffset) * 0.15; + const alpha = Math.max(0.05, Math.min(0.85, twinkle)); + + ctx.beginPath(); + ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + ctx.fill(); + } + + const cx = w / 2; + const cy = h / 2; + const r = Math.max(w, h) * 0.55; + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + grad.addColorStop(0, "rgba(124,58,237,0.04)"); + grad.addColorStop(1, "rgba(124,58,237,0)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + + rafRef.current = requestAnimationFrame(tick); + }; + + rafRef.current = requestAnimationFrame(tick); + + return () => { + ro.disconnect(); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + return ( + <div ref={containerRef} className="pointer-events-none absolute inset-0 z-0"> + <canvas + ref={canvasRef} + className="absolute inset-0 z-0 block h-full w-full" + /> + </div> + ); +} diff --git a/src/components/timeline/TimelineView.tsx b/src/components/timeline/TimelineView.tsx index 515427c..45e3d07 100644 --- a/src/components/timeline/TimelineView.tsx +++ b/src/components/timeline/TimelineView.tsx @@ -2,7 +2,7 @@ import { useTimelineFilter } from "@/features/timeline/useTimelineFilter"; import { TimelineFilters } from "./TimelineFilters"; -import { TimelineList } from "./TimelineList"; +import { DevlogTimeline } from "@/components/ui/timeline"; import type { TimelineEntry } from "@/features/timeline/types"; interface TimelineViewProps { @@ -13,8 +13,6 @@ export function TimelineView({ entries }: TimelineViewProps) { const { search, setSearch, typeFilter, setTypeFilter, filtered, counts } = useTimelineFilter(entries); - const hasActiveFilter = search.trim().length > 0 || typeFilter !== "all"; - return ( <> <TimelineFilters @@ -24,7 +22,14 @@ export function TimelineView({ entries }: TimelineViewProps) { onTypeFilterChange={setTypeFilter} counts={counts} /> - <TimelineList entries={filtered} hasActiveFilter={hasActiveFilter} /> + {filtered.length === 0 ? ( + <div className="mt-16 text-center"> + <p className="text-sm text-zinc-500">No entries match your filter.</p> + <p className="mt-1 text-xs text-zinc-600">Try clearing the search or selecting a different type.</p> + </div> + ) : ( + <DevlogTimeline entries={filtered} initialCount={6} /> + )} </> ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..d1776a2 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-violet-500 text-white hover:bg-violet-400", + destructive: + "bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20", + outline: + "border border-zinc-700 bg-transparent text-zinc-300 hover:border-zinc-600 hover:text-zinc-100", + secondary: + "bg-zinc-800 text-zinc-200 hover:bg-zinc-700", + ghost: + "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100", + link: + "text-violet-400 underline-offset-4 hover:underline hover:text-violet-300", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-6", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean; +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/src/components/ui/shape-landing-hero.tsx b/src/components/ui/shape-landing-hero.tsx index 5f5fe41..b7d8488 100644 --- a/src/components/ui/shape-landing-hero.tsx +++ b/src/components/ui/shape-landing-hero.tsx @@ -1,7 +1,8 @@ "use client"; import { motion } from "framer-motion"; -import { Circle, Github, ArrowRight, GitCommit, CheckCircle2, Zap } from "lucide-react"; +import { Github, ArrowRight } from "lucide-react"; +import AnimatedTerminal from "@/components/landing/AnimatedTerminal"; import Link from "next/link"; import { cn } from "@/lib/utils/cn"; @@ -92,6 +93,19 @@ export function LandingHero() { {/* Radial glow */} <div className="absolute inset-0 bg-hero-glow" /> + {/* Multi-point gradient — drifts very slowly for life */} + <div + className="absolute inset-0 pointer-events-none" + style={{ + background: ` + radial-gradient(ellipse 80% 50% at 20% 20%, rgba(124,58,237,0.18) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 80% 80%, rgba(59,130,246,0.10) 0%, transparent 55%), + radial-gradient(ellipse 50% 60% at 50% 100%, rgba(139,92,246,0.08) 0%, transparent 50%) + `, + animation: "shimmer-drift 18s ease-in-out infinite", + }} + /> + {/* Floating shapes — Devlog palette: violet, indigo, cyan */} <div className="absolute inset-0 overflow-hidden pointer-events-none"> <ElegantShape @@ -139,11 +153,35 @@ export function LandingHero() { {/* Hero content */} <div className="relative z-10 mx-auto max-w-4xl text-center"> {/* Eyebrow badge */} - <FadeUp delay={0.3} className="mb-8 inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1"> - <Circle className="h-2 w-2 fill-violet-500/80 text-violet-500/80" /> - <span className="text-xs tracking-wide text-white/50"> - AI-powered developer growth engine - </span> + <FadeUp delay={0.3} className="mb-8"> + <div + style={{ + background: "linear-gradient(135deg, rgba(124,58,237,0.2), rgba(59,130,246,0.1))", + border: "1px solid rgba(167,139,250,0.35)", + borderRadius: 999, + padding: "6px 16px", + display: "inline-flex", + alignItems: "center", + gap: 8, + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + }} + > + <div + className="animate-pulse" + style={{ + width: 6, + height: 6, + borderRadius: "50%", + background: "#a78bfa", + boxShadow: "0 0 8px rgba(167,139,250,0.8)", + flexShrink: 0, + }} + /> + <span style={{ color: "rgba(255,255,255,0.8)", fontSize: 13 }}> + AI-powered developer growth engine + </span> + </div> </FadeUp> {/* Headline */} @@ -153,7 +191,14 @@ export function LandingHero() { Turn your commits </span> <br /> - <span className="bg-gradient-to-r from-violet-300 via-white/90 to-indigo-300 bg-clip-text text-transparent"> + <span + style={{ + background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 40%, #6d28d9 70%, #c4b5fd 100%)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + backgroundClip: "text", + }} + > into content. </span> </h1> @@ -171,8 +216,26 @@ export function LandingHero() { {/* CTAs */} <FadeUp delay={0.84} className="flex flex-col items-center justify-center gap-3 sm:flex-row"> <Link - href="/dashboard" - className="flex items-center gap-2 rounded-lg bg-violet-500 px-5 py-2.5 font-medium text-white transition-colors hover:bg-violet-400" + href="/login" + className="flex items-center gap-2 font-semibold text-white" + style={{ + background: "linear-gradient(135deg, #7c3aed 0%, #6d28d9 50%, #5b21b6 100%)", + boxShadow: "0 4px 24px rgba(124,58,237,0.45), 0 1px 0 rgba(255,255,255,0.1) inset", + borderRadius: 12, + padding: "12px 24px", + fontSize: 15, + transition: "all 0.25s cubic-bezier(0.4,0,0.2,1)", + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.boxShadow = + "0 6px 32px rgba(124,58,237,0.65), 0 1px 0 rgba(255,255,255,0.1) inset"; + (e.currentTarget as HTMLElement).style.transform = "translateY(-2px)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.boxShadow = + "0 4px 24px rgba(124,58,237,0.45), 0 1px 0 rgba(255,255,255,0.1) inset"; + (e.currentTarget as HTMLElement).style.transform = "translateY(0)"; + }} > <Github className="h-4 w-4" /> Connect GitHub — it's free @@ -188,56 +251,7 @@ export function LandingHero() { {/* Terminal mockup */} <FadeUp delay={1.02} className="mx-auto mt-16 max-w-2xl"> - <div className="overflow-hidden rounded-xl border border-white/[0.08] bg-white/[0.03] shadow-2xl shadow-black/60 text-left backdrop-blur-sm"> - {/* Window chrome */} - <div className="flex items-center gap-1.5 border-b border-white/[0.06] bg-white/[0.02] px-4 py-2.5"> - <div className="h-2.5 w-2.5 rounded-full bg-white/10" /> - <div className="h-2.5 w-2.5 rounded-full bg-white/10" /> - <div className="h-2.5 w-2.5 rounded-full bg-white/10" /> - <span className="ml-2 font-mono text-[11px] text-white/25"> - devlog · activity feed - </span> - </div> - - {/* Content */} - <div className="space-y-4 p-5 font-mono text-sm"> - <div className="flex items-start gap-3"> - <GitCommit className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-400/80" /> - <div> - <div className="text-amber-400/80"> - commit detected - <span className="ml-2 text-white/25">· startup-validator</span> - </div> - <div className="mt-0.5 text-white/40"> - "Implemented startup scoring algorithm" - </div> - </div> - </div> - - <div className="flex items-start gap-3"> - <Zap className="mt-0.5 h-4 w-4 flex-shrink-0 text-violet-400/80" /> - <div> - <span className="text-violet-400/80">AI analyzing</span> - <span className="text-white/25"> · generating outcome-focused summary...</span> - </div> - </div> - - <div className="flex items-start gap-3"> - <CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400/80" /> - <div> - <div className="text-emerald-400/80"> - post generated - <span className="ml-2 text-white/25">· X (Twitter)</span> - </div> - <div className="mt-0.5 leading-relaxed text-white/60"> - "Day 14 building my AI startup validator. Just shipped - the scoring engine — it now evaluates ideas across market - size, competition & founder fit..." - </div> - </div> - </div> - </div> - </div> + <AnimatedTerminal /> </FadeUp> </div> diff --git a/src/components/ui/timeline.tsx b/src/components/ui/timeline.tsx new file mode 100644 index 0000000..92ef40d --- /dev/null +++ b/src/components/ui/timeline.tsx @@ -0,0 +1,519 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { useState } from "react"; +import { + GitCommit, + GitPullRequest, + GitMerge, + Tag, + ChevronDown, + Sparkles, + FileCode, + GitBranch, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils/cn"; +import type { + TimelineEntry, + CommitEntry, + PrEntry, + ReleaseEntry, +} from "@/features/timeline/types"; + +// ── Config maps ─────────────────────────────────────────────────────────── + +const ENTRY_CONFIG = { + commit: { + icon: GitCommit, + nodeBg: "bg-amber-500/10 border-amber-500/20", + nodeIcon: "text-amber-400", + label: "Commit", + labelColor: "text-amber-500", + dotColor: "bg-amber-500", + }, + pr: { + open: { + icon: GitPullRequest, + nodeBg: "bg-blue-500/10 border-blue-500/20", + nodeIcon: "text-blue-400", + label: "PR", + labelColor: "text-blue-400", + dotColor: "bg-blue-500", + badgeCls: "border-blue-500/20 bg-blue-500/10 text-blue-400", + }, + merged: { + icon: GitMerge, + nodeBg: "bg-violet-500/10 border-violet-500/20", + nodeIcon: "text-violet-400", + label: "PR", + labelColor: "text-violet-400", + dotColor: "bg-violet-500", + badgeCls: "border-violet-500/20 bg-violet-500/10 text-violet-400", + }, + closed: { + icon: GitPullRequest, + nodeBg: "bg-zinc-800 border-zinc-700", + nodeIcon: "text-zinc-500", + label: "PR", + labelColor: "text-zinc-500", + dotColor: "bg-zinc-600", + badgeCls: "border-zinc-700 bg-zinc-800 text-zinc-500", + }, + }, + release: { + icon: Tag, + nodeBg: "bg-emerald-500/10 border-emerald-500/20", + nodeIcon: "text-emerald-400", + label: "Release", + labelColor: "text-emerald-500", + dotColor: "bg-emerald-500", + }, +}; + +const POST_STATUS = { + published: { + label: "✓ published", + cls: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400", + }, + draft: { + label: "draft", + cls: "border-amber-500/20 bg-amber-500/10 text-amber-400", + }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function getConfig(entry: TimelineEntry) { + if (entry.type === "commit") return ENTRY_CONFIG.commit; + if (entry.type === "pr") return ENTRY_CONFIG.pr[entry.state]; + return ENTRY_CONFIG.release; +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); +} + +// ── Expanded detail panels ──────────────────────────────────────────────── + +function CommitDetail({ entry }: { entry: CommitEntry }) { + return ( + <div className="mt-4 space-y-3"> + <div className="flex flex-wrap gap-3"> + <Stat label="Hash" value={entry.hash} mono /> + <Stat label="Files" value={String(entry.filesChanged)} /> + <Stat + label="Changes" + value={`+${entry.additions} / −${entry.deletions}`} + valueClass="text-emerald-400" + /> + </div> + <MetaRow icon={GitBranch} text={entry.branch} /> + </div> + ); +} + +function PrDetail({ entry }: { entry: PrEntry }) { + const cfg = ENTRY_CONFIG.pr[entry.state]; + return ( + <div className="mt-4 space-y-3"> + <div className="flex flex-wrap gap-3"> + <Stat label="PR" value={`#${entry.prNumber}`} mono /> + <Stat label="Commits" value={String(entry.commits)} /> + <Stat label="Files" value={String(entry.filesChanged)} /> + </div> + <div className="flex flex-wrap items-center gap-2"> + <span + className={cn( + "rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider", + cfg.badgeCls + )} + > + {entry.state} + </span> + {entry.labels.map((l) => ( + <span + key={l} + className="rounded border border-zinc-700/50 bg-zinc-800/60 px-2 py-0.5 text-[10px] text-zinc-500" + > + {l} + </span> + ))} + </div> + <MetaRow icon={GitBranch} text={entry.branch} /> + </div> + ); +} + +function ReleaseDetail({ entry }: { entry: ReleaseEntry }) { + return ( + <div className="mt-4 space-y-3"> + <div className="flex flex-wrap gap-3"> + <Stat label="Version" value={entry.version} mono /> + <Stat label="Commits" value={String(entry.commits)} /> + </div> + <div className="rounded-lg border border-zinc-800 bg-zinc-900/60 p-3"> + <p className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-zinc-500"> + Highlights + </p> + <ul className="space-y-1.5"> + {entry.highlights.map((h) => ( + <li key={h} className="flex items-start gap-2 text-xs text-zinc-400"> + <span className="mt-1 h-1 w-1 flex-shrink-0 rounded-full bg-violet-500" /> + {h} + </li> + ))} + </ul> + </div> + </div> + ); +} + +// ── Small reusable stat chip ────────────────────────────────────────────── + +function Stat({ + label, + value, + mono, + valueClass, +}: { + label: string; + value: string; + mono?: boolean; + valueClass?: string; +}) { + return ( + <div className="rounded-md border border-zinc-800 bg-zinc-900/60 px-2.5 py-1.5"> + <p className="text-[9px] font-semibold uppercase tracking-widest text-zinc-600"> + {label} + </p> + <p + className={cn( + "mt-0.5 text-xs font-medium text-zinc-300", + mono && "font-mono", + valueClass + )} + > + {value} + </p> + </div> + ); +} + +function MetaRow({ + icon: Icon, + text, +}: { + icon: React.ElementType; + text: string; +}) { + return ( + <div className="flex items-center gap-1.5 text-[11px] text-zinc-600"> + <Icon className="h-3 w-3 flex-shrink-0" /> + <span className="font-mono truncate">{text}</span> + </div> + ); +} + +// ── Single entry (shared content) ───────────────────────────────────────── + +function EntryContent({ + entry, + isOpen, +}: { + entry: TimelineEntry; + isOpen: boolean; +}) { + const cfg = getConfig(entry); + const postCfg = entry.postStatus ? POST_STATUS[entry.postStatus] : null; + + return ( + <> + {/* Top meta */} + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 mb-1.5"> + <span className={cn("font-mono text-[10px] font-semibold uppercase tracking-wider", cfg.labelColor)}> + {"label" in cfg ? cfg.label : ""} + {entry.type === "pr" && ` #${(entry as PrEntry).prNumber}`} + {entry.type === "release" && ` ${(entry as ReleaseEntry).version}`} + </span> + <span className="text-zinc-700">·</span> + <span className="font-mono text-[11px] text-zinc-500">{entry.repo}</span> + {postCfg && ( + <> + <span className="text-zinc-700">·</span> + <span className={cn("rounded border px-1.5 py-0.5 text-[10px]", postCfg.cls)}> + {postCfg.label} + </span> + </> + )} + </div> + + {/* Title row */} + <div className="flex items-start justify-between gap-3"> + <h3 className="text-sm font-medium leading-snug text-zinc-100 transition-colors group-hover:text-white"> + {entry.title} + </h3> + <motion.div + animate={{ rotate: isOpen ? 180 : 0 }} + transition={{ duration: 0.22 }} + className="mt-0.5 flex-shrink-0 text-zinc-600" + > + <ChevronDown className="h-4 w-4" /> + </motion.div> + </div> + + {/* Expanded details */} + <AnimatePresence initial={false}> + {isOpen && ( + <motion.div + key="expanded" + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ duration: 0.28, ease: [0.25, 0.4, 0.25, 1] }} + className="overflow-hidden" + > + {/* Summary */} + <p className="mt-3 text-xs leading-relaxed text-zinc-400"> + {entry.summary} + </p> + + {/* Type-specific detail */} + {entry.type === "commit" && <CommitDetail entry={entry as CommitEntry} />} + {entry.type === "pr" && <PrDetail entry={entry as PrEntry} />} + {entry.type === "release" && <ReleaseDetail entry={entry as ReleaseEntry} />} + + {/* CTA row */} + <div className="mt-4 flex items-center justify-between border-t border-zinc-800/80 pt-3"> + <div className="flex items-center gap-1.5 text-[11px] text-zinc-600"> + <FileCode className="h-3 w-3" /> + <span className="font-mono">{entry.displayTime}</span> + </div> + <button className="flex items-center gap-1.5 rounded-md border border-violet-500/20 bg-violet-500/8 px-2.5 py-1 text-[11px] font-medium text-violet-400 transition-colors hover:border-violet-500/40 hover:text-violet-300"> + <Sparkles className="h-3 w-3" /> + {entry.postStatus ? "Edit post" : "Generate post"} + </button> + </div> + </motion.div> + )} + </AnimatePresence> + </> + ); +} + +// ── Desktop entry (date-left, spine-center, card-right) ─────────────────── + +function DesktopEntry({ + entry, + isOpen, + onToggle, +}: { + entry: TimelineEntry; + isOpen: boolean; + onToggle: () => void; +}) { + const cfg = getConfig(entry); + const NodeIcon = cfg.icon; + + return ( + <li className="hidden md:grid grid-cols-[9rem_2.5rem_1fr] gap-x-4 items-start"> + {/* Date */} + <div className="pt-3 text-right"> + <time + dateTime={entry.dateIso} + className="text-xs font-medium text-zinc-500 transition-colors group-hover:text-zinc-300" + > + {formatDate(entry.dateIso)} + </time> + <p className="mt-0.5 font-mono text-[10px] text-zinc-700"> + {entry.displayTime.includes(",") + ? entry.displayTime.split(",")[1].trim() + : ""} + </p> + </div> + + {/* Spine + node */} + <div className="relative flex flex-col items-center"> + <div + className={cn( + "z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border", + cfg.nodeBg + )} + > + <NodeIcon className={cn("h-4 w-4", cfg.nodeIcon)} /> + </div> + <div className="mt-1 flex-1 w-px bg-zinc-800" /> + </div> + + {/* Card */} + <button + onClick={onToggle} + className="group w-full cursor-pointer rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-left transition-all duration-150 hover:border-zinc-700 hover:bg-zinc-900/70 mb-2" + > + <EntryContent entry={entry} isOpen={isOpen} /> + </button> + </li> + ); +} + +// ── Mobile entry ────────────────────────────────────────────────────────── + +function MobileEntry({ + entry, + isOpen, + onToggle, +}: { + entry: TimelineEntry; + isOpen: boolean; + onToggle: () => void; +}) { + const cfg = getConfig(entry); + const NodeIcon = cfg.icon; + + return ( + <li className="md:hidden flex gap-3"> + {/* Spine + node column */} + <div className="flex flex-col items-center"> + <div + className={cn( + "z-10 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full border", + cfg.nodeBg + )} + > + <NodeIcon className={cn("h-3.5 w-3.5", cfg.nodeIcon)} /> + </div> + <div className="mt-1 flex-1 w-px bg-zinc-800" /> + </div> + + {/* Card */} + <button + onClick={onToggle} + className="group mb-3 flex-1 cursor-pointer rounded-xl border border-zinc-800 bg-zinc-900/40 p-3.5 text-left transition-all duration-150 hover:border-zinc-700 hover:bg-zinc-900/70" + > + <time className="mb-1.5 block font-mono text-[10px] text-zinc-600"> + {formatDate(entry.dateIso)} + </time> + <EntryContent entry={entry} isOpen={isOpen} /> + </button> + </li> + ); +} + +// ── Main DevlogTimeline export ──────────────────────────────────────────── + +interface DevlogTimelineProps { + entries: TimelineEntry[]; + initialCount?: number; + showMoreText?: string; + showLessText?: string; + className?: string; +} + +export function DevlogTimeline({ + entries, + initialCount = 6, + showMoreText = "Load more", + showLessText = "Show less", + className, +}: DevlogTimelineProps) { + const [showAll, setShowAll] = useState(false); + const [openIds, setOpenIds] = useState<Set<string>>(new Set()); + + const sorted = [...entries].sort( + (a, b) => new Date(b.dateIso).getTime() - new Date(a.dateIso).getTime() + ); + + const visible = showAll ? sorted : sorted.slice(0, initialCount); + const remaining = sorted.slice(initialCount); + + function toggle(id: string) { + setOpenIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + return ( + <div className={cn("w-full", className)}> + <ul className="space-y-1"> + {visible.map((entry, i) => ( + <motion.div + key={entry.id} + initial={{ opacity: 0, y: 16 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + duration: 0.35, + delay: i * 0.06, + ease: [0.25, 0.4, 0.25, 1], + }} + > + <DesktopEntry + entry={entry} + isOpen={openIds.has(entry.id)} + onToggle={() => toggle(entry.id)} + /> + <MobileEntry + entry={entry} + isOpen={openIds.has(entry.id)} + onToggle={() => toggle(entry.id)} + /> + </motion.div> + ))} + + <AnimatePresence> + {showAll && + sorted.slice(initialCount).map((entry, i) => ( + <motion.div + key={entry.id + "-extra"} + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + transition={{ duration: 0.3, delay: i * 0.05 }} + > + <DesktopEntry + entry={entry} + isOpen={openIds.has(entry.id)} + onToggle={() => toggle(entry.id)} + /> + <MobileEntry + entry={entry} + isOpen={openIds.has(entry.id)} + onToggle={() => toggle(entry.id)} + /> + </motion.div> + ))} + </AnimatePresence> + </ul> + + {remaining.length > 0 && ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.3 }} + className="mt-8 flex justify-center" + > + <Button + variant="ghost" + size="sm" + className="gap-2 text-zinc-500 hover:text-zinc-200" + onClick={() => setShowAll((v) => !v)} + > + {showAll ? showLessText : `${showMoreText} (${remaining.length})`} + <motion.span + animate={{ rotate: showAll ? 180 : 0 }} + transition={{ duration: 0.2 }} + className="inline-flex" + > + <ChevronDown className="h-4 w-4" /> + </motion.span> + </Button> + </motion.div> + )} + </div> + ); +} diff --git a/src/features/github/mapCommitsToTimeline.ts b/src/features/github/mapCommitsToTimeline.ts new file mode 100644 index 0000000..db8f72f --- /dev/null +++ b/src/features/github/mapCommitsToTimeline.ts @@ -0,0 +1,50 @@ +import type { GitHubCommit } from "@/lib/github/types"; +import type { CommitEntry } from "@/features/timeline/types"; + +function formatDisplayTime(isoDate: string): string { + const date = new Date(isoDate); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + const time = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + if (isToday) return `Today, ${time}`; + + const dateStr = date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + + return `${dateStr}, ${time}`; +} + +function firstLine(message: string): string { + return message.split("\n")[0].trim(); +} + +export function mapCommitsToTimeline( + commits: GitHubCommit[], + repoFullName: string, +): CommitEntry[] { + return commits.map((commit) => ({ + id: `gh-commit-${commit.sha}`, + type: "commit" as const, + title: firstLine(commit.commit.message), + summary: commit.commit.message.length > firstLine(commit.commit.message).length + ? commit.commit.message.slice(firstLine(commit.commit.message).length).trim() + : `Commit by ${commit.commit.author.name}`, + repo: repoFullName.split("/").pop() ?? repoFullName, + branch: "main", + dateIso: commit.commit.author.date, + displayTime: formatDisplayTime(commit.commit.author.date), + hash: commit.sha.slice(0, 7), + filesChanged: commit.files?.length ?? 0, + additions: commit.stats?.additions ?? 0, + deletions: commit.stats?.deletions ?? 0, + postStatus: null, + })); +} diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts new file mode 100644 index 0000000..e285a3a --- /dev/null +++ b/src/hooks/useInView.ts @@ -0,0 +1,27 @@ +import { useRef, useState, useEffect } from "react"; + +// Returns inView as a live boolean — true while the element intersects the +// viewport, false when it scrolls out. Animations reset cleanly if the user +// scrolls back up. +export function useInView( + options: IntersectionObserverInit = { threshold: 0.2 }, +): { ref: React.RefObject<HTMLDivElement>; inView: boolean } { + const ref = useRef<HTMLDivElement>(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver(([entry]) => { + setInView(entry.isIntersecting); + }, options); + + observer.observe(el); + return () => observer.disconnect(); + // options object is intentionally excluded — callers pass a stable literal + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { ref, inView }; +} diff --git a/src/hooks/useTypewriter.ts b/src/hooks/useTypewriter.ts new file mode 100644 index 0000000..fe66673 --- /dev/null +++ b/src/hooks/useTypewriter.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from "react"; + +export function useTypewriter( + text: string, + speed: number = 28, + startDelay: number = 0, + enabled: boolean = true, +): { displayed: string; isDone: boolean } { + const [displayed, setDisplayed] = useState(""); + const [isDone, setIsDone] = useState(false); + + useEffect(() => { + setDisplayed(""); + setIsDone(false); + + if (!enabled) return; + + let interval: ReturnType<typeof setInterval>; + + const timeout = setTimeout(() => { + let index = 0; + + interval = setInterval(() => { + index += 1; + setDisplayed(text.slice(0, index)); + + if (index >= text.length) { + clearInterval(interval); + setIsDone(true); + } + }, speed); + }, startDelay); + + return () => { + clearTimeout(timeout); + clearInterval(interval); + }; + }, [text, speed, startDelay, enabled]); + + return { displayed, isDone }; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..115f43b --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,51 @@ +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import type { NextAuthOptions } from "next-auth"; +import GitHubProvider from "next-auth/providers/github"; +import { prisma } from "@/lib/prisma"; + +export const authOptions: NextAuthOptions = { + adapter: PrismaAdapter(prisma), + providers: [ + GitHubProvider({ + clientId: process.env.GITHUB_CLIENT_ID?.trim() ?? "", + clientSecret: process.env.GITHUB_CLIENT_SECRET?.trim() ?? "", + authorization: { + params: { + scope: "read:user user:email repo", + }, + }, + }), + ], + pages: { + signIn: "/login", + }, + session: { + strategy: "database", + }, + callbacks: { + async session({ session, user }) { + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { selectedGithubRepo: true }, + }); + if (session.user) { + session.user.id = user.id; + session.user.selectedGithubRepo = row?.selectedGithubRepo ?? null; + } + return session; + }, + }, + secret: process.env.NEXTAUTH_SECRET?.trim(), + logger: { + error(code, metadata) { + console.error("[NextAuth Error]", code, metadata); + }, + }, + events: { + async signIn(message) { + if (process.env.NODE_ENV !== "production") { + console.log("[NextAuth signIn]", message); + } + }, + }, +}; diff --git a/src/lib/github/api.ts b/src/lib/github/api.ts new file mode 100644 index 0000000..cf6543d --- /dev/null +++ b/src/lib/github/api.ts @@ -0,0 +1,118 @@ +import type { GitHubRepo, GitHubCommit, GitHubPullRequest, GitHubRelease } from "./types"; + +const GITHUB_API_BASE = "https://api.github.com"; + +function authHeaders(token: string) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }; +} + +export async function fetchUserRepos( + token: string, + options: { sort?: string; perPage?: number } = {}, +): Promise<GitHubRepo[]> { + if (!token) { + throw new Error("fetchUserRepos: GitHub access token is missing."); + } + + const { sort = "pushed", perPage = 50 } = options; + const params = new URLSearchParams({ + sort, + per_page: String(perPage), + direction: "desc", + }); + + const res = await fetch(`${GITHUB_API_BASE}/user/repos?${params}`, { + headers: authHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch repos: ${res.status}`); + } + + return res.json(); +} + +export async function fetchRepoCommits( + token: string, + owner: string, + repo: string, + options: { perPage?: number; sha?: string } = {}, +): Promise<GitHubCommit[]> { + if (!token) { + throw new Error("fetchRepoCommits: GitHub access token is missing."); + } + + const { perPage = 20, sha } = options; + const params = new URLSearchParams({ per_page: String(perPage) }); + if (sha) params.set("sha", sha); + + const res = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/commits?${params}`, + { headers: authHeaders(token) }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch commits: ${res.status}`); + } + + return res.json(); +} + +export async function fetchRepoPullRequests( + token: string, + owner: string, + repo: string, + options: { perPage?: number } = {}, +): Promise<GitHubPullRequest[]> { + if (!token) { + throw new Error("fetchRepoPullRequests: GitHub access token is missing."); + } + + const { perPage = 20 } = options; + const params = new URLSearchParams({ + state: "closed", + sort: "updated", + per_page: String(perPage), + }); + + const res = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?${params}`, + { headers: authHeaders(token) }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch pull requests: ${res.status}`); + } + + return res.json(); +} + +export async function fetchRepoReleases( + token: string, + owner: string, + repo: string, + options: { perPage?: number } = {}, +): Promise<GitHubRelease[]> { + if (!token) { + throw new Error("fetchRepoReleases: GitHub access token is missing."); + } + + const { perPage = 10 } = options; + const params = new URLSearchParams({ + per_page: String(perPage), + }); + + const res = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/releases?${params}`, + { headers: authHeaders(token) }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch releases: ${res.status}`); + } + + return res.json(); +} diff --git a/src/lib/github/index.ts b/src/lib/github/index.ts new file mode 100644 index 0000000..74f863a --- /dev/null +++ b/src/lib/github/index.ts @@ -0,0 +1,11 @@ +export { buildAuthUrl, exchangeCodeForToken, fetchAuthenticatedUser } from "./oauth"; +export { fetchUserRepos, fetchRepoCommits, fetchRepoPullRequests, fetchRepoReleases } from "./api"; +export type { + GitHubUser, + GitHubRepo, + GitHubCommit, + GitHubPullRequest, + GitHubRelease, + GitHubTokenResponse, + GitHubConnectionStatus, +} from "./types"; diff --git a/src/lib/github/normalizeEvents.ts b/src/lib/github/normalizeEvents.ts new file mode 100644 index 0000000..e3dfa38 --- /dev/null +++ b/src/lib/github/normalizeEvents.ts @@ -0,0 +1,129 @@ +import type { + CommitEntry, + PrEntry, + PrState, + ReleaseEntry, + TimelineEntry, +} from "@/features/timeline/types"; +import type { GitHubCommit, GitHubPullRequest, GitHubRelease } from "./types"; + +/** + * Maps raw GitHub API objects to the existing TimelineEntry shape so the + * timeline UI can render them with no changes. + * + * Field-name reconciliation (task spec → actual TimelineEntry): + * - `eventType` → `type` + * - `pull_request` → `"pr"` (existing discriminator value) + * - `createdAt` → `dateIso` + * - `privacyLevel` → no equivalent field on TimelineEntry; we set + * `postStatus: null` (= "not yet posted") as the closest + * existing analog. Privacy classification is a downstream + * concern that should live on a separate field. + */ + +const SUMMARY_MAX = 500; + +function truncate(value: string | null | undefined, max = SUMMARY_MAX): string { + if (!value) return ""; + const trimmed = value.trim(); + return trimmed.length > max ? `${trimmed.slice(0, max - 1).trimEnd()}…` : trimmed; +} + +function firstLine(value: string | null | undefined): string { + if (!value) return ""; + const idx = value.indexOf("\n"); + return (idx === -1 ? value : value.slice(0, idx)).trim(); +} + +function formatDisplayTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +function toPrState(pr: GitHubPullRequest): PrState { + if (pr.merged_at) return "merged"; + if (pr.state === "closed") return "closed"; + return "open"; +} + +export function normalizeCommit( + commit: GitHubCommit, + repoName: string, +): CommitEntry { + const message = commit.commit?.message ?? ""; + const dateIso = commit.commit?.author?.date ?? new Date().toISOString(); + + return { + id: commit.sha, + type: "commit", + title: firstLine(message) || commit.sha.slice(0, 7), + summary: truncate(message), + repo: repoName, + branch: "", + dateIso, + displayTime: formatDisplayTime(dateIso), + hash: commit.sha.slice(0, 7), + filesChanged: commit.files?.length ?? 0, + additions: commit.stats?.additions ?? 0, + deletions: commit.stats?.deletions ?? 0, + postStatus: null, + }; +} + +export function normalizePullRequest( + pr: GitHubPullRequest, + repoName: string, +): PrEntry { + const dateIso = pr.updated_at ?? pr.created_at ?? new Date().toISOString(); + const labels = Array.isArray((pr as unknown as { labels?: { name: string }[] }).labels) + ? ((pr as unknown as { labels: { name: string }[] }).labels.map((l) => l.name)) + : []; + + return { + id: String(pr.id), + type: "pr", + title: pr.title || `PR #${pr.number}`, + summary: truncate(pr.body), + repo: repoName, + branch: "", + dateIso, + displayTime: formatDisplayTime(dateIso), + prNumber: pr.number, + state: toPrState(pr), + labels, + commits: 0, + filesChanged: 0, + postStatus: null, + }; +} + +export function normalizeRelease( + release: GitHubRelease, + repoName: string, +): ReleaseEntry { + const dateIso = + release.published_at ?? release.created_at ?? new Date().toISOString(); + + return { + id: String(release.id), + type: "release", + title: release.name?.trim() || release.tag_name, + summary: truncate(release.body), + repo: repoName, + branch: "", + dateIso, + displayTime: formatDisplayTime(dateIso), + version: release.tag_name, + highlights: [], + commits: 0, + postStatus: null, + }; +} + +export type NormalizedTimelineEntry = TimelineEntry; diff --git a/src/lib/github/oauth.ts b/src/lib/github/oauth.ts new file mode 100644 index 0000000..67b60eb --- /dev/null +++ b/src/lib/github/oauth.ts @@ -0,0 +1,64 @@ +import type { GitHubTokenResponse, GitHubUser } from "./types"; + +const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"; +const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const GITHUB_API_BASE = "https://api.github.com"; + +const SCOPES = "repo read:user"; + +export function buildAuthUrl(clientId: string, callbackUrl: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: callbackUrl, + scope: SCOPES, + }); + return `${GITHUB_AUTH_URL}?${params.toString()}`; +} + +export async function exchangeCodeForToken( + code: string, + clientId: string, + clientSecret: string, +): Promise<GitHubTokenResponse> { + const res = await fetch(GITHUB_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + }), + }); + + if (!res.ok) { + throw new Error(`GitHub token exchange failed: ${res.status}`); + } + + const data = await res.json(); + + if (data.error) { + throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`); + } + + return data as GitHubTokenResponse; +} + +export async function fetchAuthenticatedUser( + accessToken: string, +): Promise<GitHubUser> { + const res = await fetch(`${GITHUB_API_BASE}/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch GitHub user: ${res.status}`); + } + + return res.json(); +} diff --git a/src/lib/github/types.ts b/src/lib/github/types.ts new file mode 100644 index 0000000..f486286 --- /dev/null +++ b/src/lib/github/types.ts @@ -0,0 +1,95 @@ +/** Raw GitHub API response shapes used by the GitHub lib layer. */ + +export interface GitHubUser { + login: string; + avatar_url: string; + html_url: string; + name: string | null; +} + +export interface GitHubRepo { + id: number; + name: string; + full_name: string; + private: boolean; + html_url: string; + description: string | null; + language: string | null; + stargazers_count: number; + default_branch: string; + pushed_at: string | null; + updated_at: string; + owner: { + login: string; + avatar_url: string; + }; +} + +export interface GitHubCommit { + sha: string; + html_url: string; + commit: { + message: string; + author: { + name: string; + email: string; + date: string; + }; + }; + stats?: { + total: number; + additions: number; + deletions: number; + }; + files?: { + filename: string; + additions: number; + deletions: number; + changes: number; + status: string; + }[]; +} + +export interface GitHubPullRequest { + id: number; + number: number; + title: string; + state: string; + html_url: string; + created_at: string; + updated_at: string; + merged_at: string | null; + user: { + login: string; + avatar_url: string; + }; + body: string | null; +} + +export interface GitHubRelease { + id: number; + tag_name: string; + name: string | null; + draft: boolean; + prerelease: boolean; + created_at: string; + published_at: string | null; + html_url: string; + body: string | null; + author: { + login: string; + avatar_url: string; + }; +} + +export interface GitHubTokenResponse { + access_token: string; + token_type: string; + scope: string; +} + +export interface GitHubConnectionStatus { + connected: boolean; + user: { login: string; avatarUrl: string } | null; + selectedRepo: string | null; +} diff --git a/src/lib/postGenerator/enrichEvent.ts b/src/lib/postGenerator/enrichEvent.ts new file mode 100644 index 0000000..b31c53d --- /dev/null +++ b/src/lib/postGenerator/enrichEvent.ts @@ -0,0 +1,202 @@ +import type { TimelineEntry } from "@/features/timeline/types"; + +export type EnrichmentDifficulty = "trivial" | "moderate" | "significant"; + +export type EnrichedEvent = { + originalEntry: TimelineEntry; + whatChanged: string; + whyItMatters: string; + technicalDetail: string; + outcome: string; + difficulty: EnrichmentDifficulty; +}; + +const ENRICH_MODEL = "claude-haiku-4-5-20251001"; +const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"; +const ANTHROPIC_VERSION = "2023-06-01"; +const MAX_TOKENS = 600; + +const DIFFICULTIES: readonly EnrichmentDifficulty[] = [ + "trivial", + "moderate", + "significant", +]; + +function describeEntry(entry: TimelineEntry): string { + const lines: string[] = [ + `Type: ${entry.type}`, + `Repo: ${entry.repo}`, + `Branch: ${entry.branch}`, + `Title: ${entry.title}`, + `Summary: ${entry.summary}`, + ]; + + if (entry.type === "commit") { + lines.push( + `Hash: ${entry.hash}`, + `Files changed: ${entry.filesChanged}`, + `Additions: ${entry.additions}`, + `Deletions: ${entry.deletions}`, + ); + } else if (entry.type === "pr") { + lines.push( + `PR #${entry.prNumber}`, + `State: ${entry.state}`, + `Labels: ${entry.labels.join(", ") || "(none)"}`, + `Commits: ${entry.commits}`, + `Files changed: ${entry.filesChanged}`, + ); + } else if (entry.type === "release") { + lines.push( + `Version: ${entry.version}`, + `Highlights: ${entry.highlights.join("; ") || "(none)"}`, + `Commits: ${entry.commits}`, + ); + } + + return lines.join("\n"); +} + +function buildPrompt(entry: TimelineEntry): string { + return [ + "You are analyzing a single software development event so it can later be turned into a social media post.", + "Expand the raw event into richer context: what was actually built or fixed, the user-facing or developer-facing impact, the implementation specifics, and what is now possible that wasn't before.", + "", + "Event:", + describeEntry(entry), + "", + "Rules:", + '- "whatChanged": one sentence describing what was built or fixed.', + '- "whyItMatters": one sentence on the user-facing or developer-facing impact.', + '- "technicalDetail": implementation specifics (frameworks, files, approach). May include code-level detail; downstream code will filter by privacy level.', + '- "outcome": what is now possible that wasn\'t before.', + '- "difficulty": one of "trivial", "moderate", or "significant" based on perceived effort and risk.', + "", + "Return ONLY valid JSON with no markdown, no backticks, no preamble. Keys: whatChanged, whyItMatters, technicalDetail, outcome, difficulty", + ].join("\n"); +} + +function extractJsonObject(text: string): unknown { + let candidate = text.trim(); + + const fenced = candidate.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fenced) candidate = fenced[1].trim(); + + if (!candidate.startsWith("{")) { + const start = candidate.indexOf("{"); + const end = candidate.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Model response did not contain a JSON object."); + } + candidate = candidate.slice(start, end + 1); + } + + return JSON.parse(candidate); +} + +function coerceEnrichment( + value: unknown, + entry: TimelineEntry, +): Omit<EnrichedEvent, "originalEntry"> { + if (!value || typeof value !== "object") { + throw new Error("Enrichment payload is not an object."); + } + const o = value as Record<string, unknown>; + + const whatChanged = + typeof o.whatChanged === "string" && o.whatChanged.trim().length > 0 + ? o.whatChanged.trim() + : entry.title; + const whyItMatters = + typeof o.whyItMatters === "string" && o.whyItMatters.trim().length > 0 + ? o.whyItMatters.trim() + : entry.summary; + const technicalDetail = + typeof o.technicalDetail === "string" && o.technicalDetail.trim().length > 0 + ? o.technicalDetail.trim() + : entry.summary; + const outcome = + typeof o.outcome === "string" && o.outcome.trim().length > 0 + ? o.outcome.trim() + : entry.summary; + + const rawDifficulty = + typeof o.difficulty === "string" ? o.difficulty.trim().toLowerCase() : ""; + const difficulty: EnrichmentDifficulty = (DIFFICULTIES as readonly string[]).includes( + rawDifficulty, + ) + ? (rawDifficulty as EnrichmentDifficulty) + : "moderate"; + + return { whatChanged, whyItMatters, technicalDetail, outcome, difficulty }; +} + +function fallbackEnrichment(entry: TimelineEntry): EnrichedEvent { + return { + originalEntry: entry, + whatChanged: entry.title, + whyItMatters: entry.summary, + technicalDetail: entry.summary, + outcome: entry.summary, + difficulty: "moderate", + }; +} + +export async function enrichEvent(entry: TimelineEntry): Promise<EnrichedEvent> { + const apiKey = process.env.ANTHROPIC_API_KEY?.trim(); + if (!apiKey) { + console.error("[enrichEvent] ANTHROPIC_API_KEY is not set; using fallback."); + return fallbackEnrichment(entry); + } + + const prompt = buildPrompt(entry); + + try { + const res = await fetch(ANTHROPIC_URL, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: ENRICH_MODEL, + max_tokens: MAX_TOKENS, + messages: [{ role: "user", content: prompt }], + }), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + console.error( + `[enrichEvent] Anthropic ${res.status} for entry ${entry.id}: ${errText}`, + ); + return fallbackEnrichment(entry); + } + + const payload = (await res.json()) as { + content?: Array<{ type: string; text?: string }>; + }; + const text = + payload.content + ?.filter((c) => c.type === "text" && typeof c.text === "string") + .map((c) => c.text as string) + .join("\n") ?? ""; + + if (!text) { + console.error(`[enrichEvent] Empty model response for entry ${entry.id}.`); + return fallbackEnrichment(entry); + } + + const parsed = extractJsonObject(text); + const fields = coerceEnrichment(parsed, entry); + return { originalEntry: entry, ...fields }; + } catch (err) { + console.error("[enrichEvent] failed:", err); + const message = err instanceof Error ? err.message : String(err); + console.error( + `[enrichEvent] Failed to enrich entry ${entry.id}: ${message}`, + ); + return fallbackEnrichment(entry); + } +} diff --git a/src/lib/postGenerator/generatePost.ts b/src/lib/postGenerator/generatePost.ts new file mode 100644 index 0000000..72ea149 --- /dev/null +++ b/src/lib/postGenerator/generatePost.ts @@ -0,0 +1,134 @@ +import type { EnrichedEvent } from "./enrichEvent"; +import { buildLinkedInPrompt } from "./templates/linkedinTemplate"; +import { buildXPrompt } from "./templates/xTemplate"; +import { buildRedditPrompt } from "./templates/redditTemplate"; + +export type Platform = "x" | "linkedin" | "reddit"; + +export type GeneratedPost = { + platform: Platform; + content: string; + characterCount: number; +}; + +const GENERATE_MODEL = "claude-sonnet-4-6"; +const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"; +const ANTHROPIC_VERSION = "2023-06-01"; +const MAX_TOKENS = 1000; + +const X_HARD_LIMIT = 280; +const X_SHORTEN_TARGET = 260; + +function selectPromptBuilder( + platform: Platform, +): (event: EnrichedEvent, tone: string) => string { + switch (platform) { + case "linkedin": + return buildLinkedInPrompt; + case "x": + return buildXPrompt; + case "reddit": + return buildRedditPrompt; + } +} + +async function callAnthropic( + apiKey: string, + messages: Array<{ role: "user" | "assistant"; content: string }>, +): Promise<string> { + const res = await fetch(ANTHROPIC_URL, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: GENERATE_MODEL, + max_tokens: MAX_TOKENS, + messages, + }), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error( + `Anthropic ${res.status}${errText ? `: ${errText.slice(0, 200)}` : ""}`, + ); + } + + const payload = (await res.json()) as { + content?: Array<{ type: string; text?: string }>; + }; + + const text = + payload.content + ?.filter((c) => c.type === "text" && typeof c.text === "string") + .map((c) => c.text as string) + .join("\n") + .trim() ?? ""; + + if (!text) { + throw new Error("Empty model response."); + } + + return text; +} + +function cleanModelOutput(text: string): string { + let out = text.trim(); + + const fenced = out.match(/^```(?:\w+)?\s*([\s\S]*?)\s*```$/); + if (fenced) out = fenced[1].trim(); + + if ( + (out.startsWith('"') && out.endsWith('"')) || + (out.startsWith("“") && out.endsWith("”")) || + (out.startsWith("'") && out.endsWith("'")) + ) { + out = out.slice(1, -1).trim(); + } + + return out; +} + +export async function generatePost( + event: EnrichedEvent, + platform: Platform, + tone: string, +): Promise<GeneratedPost> { + console.log("[generatePost] calling API for platform:", platform); + try { + const apiKey = process.env.ANTHROPIC_API_KEY?.trim(); + if (!apiKey) { + throw new Error("ANTHROPIC_API_KEY is not set."); + } + + const buildPrompt = selectPromptBuilder(platform); + const prompt = buildPrompt(event, tone); + + let raw = await callAnthropic(apiKey, [{ role: "user", content: prompt }]); + let content = cleanModelOutput(raw); + + if (platform === "x" && content.length > X_HARD_LIMIT) { + const followUp = `The previous response was ${content.length} characters. Shorten it to under ${X_SHORTEN_TARGET} characters while keeping the hook. Return ONLY the shortened post text.`; + + raw = await callAnthropic(apiKey, [ + { role: "user", content: prompt }, + { role: "assistant", content }, + { role: "user", content: followUp }, + ]); + content = cleanModelOutput(raw); + } + + return { + platform, + content, + characterCount: content.length, + }; + } catch (err) { + console.error("[generatePost] failed:", platform, err); + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to generate ${platform} post: ${message}`); + } +} diff --git a/src/lib/postGenerator/sanitizeEvent.ts b/src/lib/postGenerator/sanitizeEvent.ts new file mode 100644 index 0000000..aecfec2 --- /dev/null +++ b/src/lib/postGenerator/sanitizeEvent.ts @@ -0,0 +1,64 @@ +import type { EnrichedEvent } from "./enrichEvent"; + +type PrivacyLevel = "high" | "medium" | "low"; + +/** + * Patterns that suggest technical internals: file paths, function/method calls, + * variable-like tokens, error class names, and stack trace lines. + */ +const FILE_PATH_RE = /(\b[\w-]+\/)+[\w.-]+\.\w{1,6}/g; +const FUNCTION_CALL_RE = /\b[a-zA-Z_$][\w$]*\s*\(/g; +const VARIABLE_LIKE_RE = /\b(?:const|let|var|function|class|return|throw|new|import|export)\s+\w+/g; +const ERROR_MESSAGE_RE = /\b[A-Z][a-zA-Z]*(?:Error|Exception|Warning|Failure)\b[^.!?\n]*/g; +const CODE_SNIPPET_RE = /`[^`\n]{1,200}`|```[\s\S]*?```/g; +const STACK_TRACE_LINE_RE = /^\s+at\s+.+$/gm; + +function stripCodeAndStackTraces(text: string): string { + return text + .replace(CODE_SNIPPET_RE, "[code snippet]") + .replace(STACK_TRACE_LINE_RE, "") + .trim(); +} + +function redactInternalDetails(text: string): string { + let out = text; + out = out.replace(FILE_PATH_RE, "[internal detail]"); + out = out.replace(FUNCTION_CALL_RE, "[internal detail]("); + out = out.replace(VARIABLE_LIKE_RE, "[internal detail]"); + out = out.replace(ERROR_MESSAGE_RE, "[internal detail]"); + out = out.replace(CODE_SNIPPET_RE, "[internal detail]"); + return out.trim(); +} + +function applyHigh(event: EnrichedEvent): EnrichedEvent { + return { + ...event, + whatChanged: redactInternalDetails(event.whatChanged), + technicalDetail: "", + outcome: redactInternalDetails(event.outcome), + }; +} + +function applyMedium(event: EnrichedEvent): EnrichedEvent { + return { + ...event, + whatChanged: stripCodeAndStackTraces(event.whatChanged), + whyItMatters: stripCodeAndStackTraces(event.whyItMatters), + technicalDetail: "Implementation details hidden.", + outcome: stripCodeAndStackTraces(event.outcome), + }; +} + +export function sanitizeEvent( + event: EnrichedEvent, + privacyLevel: PrivacyLevel, +): EnrichedEvent { + switch (privacyLevel) { + case "high": + return applyHigh(event); + case "medium": + return applyMedium(event); + case "low": + return event; + } +} diff --git a/src/lib/postGenerator/templates/linkedinTemplate.ts b/src/lib/postGenerator/templates/linkedinTemplate.ts new file mode 100644 index 0000000..ca09c74 --- /dev/null +++ b/src/lib/postGenerator/templates/linkedinTemplate.ts @@ -0,0 +1,70 @@ +import type { EnrichedEvent } from "../enrichEvent"; + +type KnownTone = "casual" | "professional" | "feedback-seeking" | "educational"; + +const TONE_GUIDANCE: Record<KnownTone, string> = { + casual: + "Tone: casual. Write in first person and keep it conversational, like talking to a friend who codes. A soft \"check it out\" line at the end is allowed if it feels natural.", + professional: + "Tone: professional. Be reflective and measured. Avoid slang. No \"check it out\" CTAs.", + "feedback-seeking": + "Tone: feedback-seeking. End the post with one genuine, open question that invites discussion (not a rhetorical one). No \"check it out\" CTAs.", + educational: + "Tone: educational. Center the takeaway and frame it as a broadly applicable lesson — useful even to someone who has never seen this codebase. No \"check it out\" CTAs.", +}; + +const FALLBACK_TONE_GUIDANCE = + "Tone: neutral and reflective. Avoid slang and hype. No \"check it out\" CTAs."; + +function toneInstruction(tone: string): string { + const key = tone.trim().toLowerCase(); + if ((Object.keys(TONE_GUIDANCE) as KnownTone[]).includes(key as KnownTone)) { + return TONE_GUIDANCE[key as KnownTone]; + } + return FALLBACK_TONE_GUIDANCE; +} + +function technicalDetailBlock(technicalDetail: string): string { + const trimmed = technicalDetail.trim(); + if (!trimmed) { + return "Technical detail: (withheld — privacy level high; do NOT invent or infer specifics, keep the post outcome-focused)."; + } + return `Technical detail: ${trimmed}`; +} + +export function buildLinkedInPrompt( + event: EnrichedEvent, + tone: string, +): string { + const { whatChanged, whyItMatters, outcome, technicalDetail } = event; + + return [ + "You are writing a single LinkedIn post for a software developer reflecting on their work.", + "", + "Source material — use these and ONLY these facts (do not fabricate metrics, names, or impact):", + `- whatChanged: ${whatChanged}`, + `- whyItMatters: ${whyItMatters}`, + `- outcome: ${outcome}`, + `- ${technicalDetailBlock(technicalDetail)}`, + "", + toneInstruction(tone), + "", + "Hard requirements:", + "- Length: 150–300 words. Count words; do not exceed 300.", + "- Structure (in this order):", + " 1. ONE opening line that hooks with the problem or challenge. No hype openers.", + " 2. 2–3 lines on what was built and how.", + " 3. 1–2 lines on why it matters or what is different now.", + " 4. ONE takeaway or lesson learned.", + " 5. ONE soft closing line. No hard sell. No \"check it out\" unless the tone is casual.", + "- Use plain line breaks between sections (LinkedIn-friendly), not Markdown.", + "- At most 2 hashtags total, placed on the final line. Zero hashtags is fine.", + "- NEVER start with \"I am excited to share\", \"Thrilled to announce\", \"Excited to announce\", \"Happy to share\", or any similar hype phrasing.", + "- No emojis unless the tone is casual, and even then keep it to at most one.", + "- Do not use exclamation marks more than once in the entire post.", + "", + "Output format:", + "- Return ONLY the post text.", + "- No explanation, no preamble, no surrounding quotation marks, no Markdown code fences.", + ].join("\n"); +} diff --git a/src/lib/postGenerator/templates/redditTemplate.ts b/src/lib/postGenerator/templates/redditTemplate.ts new file mode 100644 index 0000000..68a2936 --- /dev/null +++ b/src/lib/postGenerator/templates/redditTemplate.ts @@ -0,0 +1,99 @@ +import type { EnrichedEvent } from "../enrichEvent"; + +type KnownTone = "casual" | "professional" | "feedback-seeking" | "educational"; + +const TONE_GUIDANCE: Record<KnownTone, string> = { + casual: + "Tone: casual. More informal phrasing is fine; light self-deprecating humor is welcome if it fits. Stay honest — do not become flippant.", + professional: + "Tone: professional. Still honest and self-aware, but more measured and structured. No slang, no jokes.", + "feedback-seeking": + "Tone: feedback-seeking. End the body with ONE specific, technical question that someone in the subreddit could actually answer (not a vague \"thoughts?\").", + educational: + "Tone: educational. Frame the whole post as a lesson you learned, written so a less-experienced developer reading the thread could take something useful away.", +}; + +const FALLBACK_TONE_GUIDANCE = + "Tone: neutral. Stay honest, factual, and self-aware. No slang, no marketing language."; + +const BANNED_WORDS = [ + "excited", + "thrilled", + "game-changer", + "game changer", + "journey", + "passionate", + "leverage", + "synergy", + "revolutionary", + "cutting-edge", + "delighted", + "stoked", +]; + +function toneInstruction(tone: string): string { + const key = tone.trim().toLowerCase(); + if ((Object.keys(TONE_GUIDANCE) as KnownTone[]).includes(key as KnownTone)) { + return TONE_GUIDANCE[key as KnownTone]; + } + return FALLBACK_TONE_GUIDANCE; +} + +function technicalDetailBlock(technicalDetail: string): string { + const trimmed = technicalDetail.trim(); + if (!trimmed) { + return "Technical detail: (withheld — privacy level high; do NOT invent specifics, file names, or stack traces. Keep the post about the experience and outcome.)"; + } + return `Technical detail: ${trimmed}`; +} + +export function buildRedditPrompt( + event: EnrichedEvent, + tone: string, +): string { + const { whatChanged, whyItMatters, technicalDetail, outcome, difficulty } = + event; + + return [ + "You are writing a single Reddit post for a developer sharing a recent change to their project.", + "The post should fit naturally in r/programming, r/webdev, or r/devlog — communities that downvote marketing language and reward honest, self-aware writing.", + "", + "Source material — use these and ONLY these facts (do not invent metrics, library names, or outcomes):", + `- whatChanged: ${whatChanged}`, + `- whyItMatters: ${whyItMatters}`, + `- ${technicalDetailBlock(technicalDetail)}`, + `- outcome: ${outcome}`, + `- difficulty: ${difficulty} (use this to calibrate how big a deal you make this — \"trivial\" should not sound heroic)`, + "", + toneInstruction(tone), + "", + "Voice rules (apply regardless of tone):", + "- Always lean honest and self-aware. Reddit rewards vulnerability over polish.", + "- Be willing to mention what was confusing, what didn't work the first time, or what surprised you.", + "- Sound like a person typing in a thread, not a press release.", + "", + "Banned vocabulary — do NOT use any of these words or close variants:", + ` ${BANNED_WORDS.join(", ")}.`, + "Avoid all startup/LinkedIn-style vocabulary in general.", + "", + "Hard requirements:", + "- Title:", + " - First line of the output, with NO prefix like \"Title:\".", + " - At most 12 words.", + " - Factual, not clickbait. No emojis. No ALL CAPS. No question marks unless the post genuinely asks the community something.", + "- Body structure (in this order, as flowing prose with paragraph breaks — not bullet headings):", + " 1. What I was trying to do.", + " 2. What actually happened, including any struggles or surprises.", + " 3. What I learned or what's next.", + " 4. (Optional) ONE genuine question for the community. Required if the tone is feedback-seeking.", + "- No hashtags anywhere.", + "- No links unless they appear verbatim in the source material.", + "- No emojis except where the casual tone would naturally include one (max one for the entire post).", + "", + "Output format (exactly this shape):", + " <title line>", + " <one blank line>", + " <body>", + "Return ONLY that. No preamble, no explanation, no surrounding quotation marks, no Markdown code fences.", + ].join("\n"); +} diff --git a/src/lib/postGenerator/templates/xTemplate.ts b/src/lib/postGenerator/templates/xTemplate.ts new file mode 100644 index 0000000..58fea2d --- /dev/null +++ b/src/lib/postGenerator/templates/xTemplate.ts @@ -0,0 +1,72 @@ +import type { EnrichedEvent } from "../enrichEvent"; + +type KnownTone = "casual" | "professional" | "feedback-seeking" | "educational"; + +const TONE_GUIDANCE: Record<KnownTone, string> = { + casual: + "Tone: casual. Punchy and direct. Sentence fragments are fine. Sound like a person, not a brand.", + professional: + "Tone: professional. Complete sentences, measured wording. No slang.", + "feedback-seeking": + "Tone: feedback-seeking. End with one short, genuine question (≤8 words). No \"thoughts?\".", + educational: + "Tone: educational. Lead with the insight in the first sentence — the takeaway IS the hook.", +}; + +const FALLBACK_TONE_GUIDANCE = + "Tone: neutral. Punchy and factual. No slang, no marketing language."; + +function toneInstruction(tone: string): string { + const key = tone.trim().toLowerCase(); + if ((Object.keys(TONE_GUIDANCE) as KnownTone[]).includes(key as KnownTone)) { + return TONE_GUIDANCE[key as KnownTone]; + } + return FALLBACK_TONE_GUIDANCE; +} + +function difficultyNote( + difficulty: EnrichedEvent["difficulty"], +): string { + if (difficulty === "significant") { + return "Difficulty: significant — this was a non-trivial change. It's worth letting the post reflect that, but never inflate it."; + } + if (difficulty === "trivial") { + return "Difficulty: trivial — keep the post understated. Do NOT make a small change sound heroic."; + } + return "Difficulty: moderate — match the size of the change without overselling."; +} + +export function buildXPrompt(event: EnrichedEvent, tone: string): string { + const { whatChanged, outcome, difficulty } = event; + + return [ + "You are writing a single X (Twitter) post for a developer sharing a recent change to their project.", + "On X, the first 8 words decide whether someone keeps reading. The hook carries the post; everything else supports it.", + "", + "Source material — use these and ONLY these facts (do not invent metrics, names, or impact):", + `- whatChanged: ${whatChanged}`, + `- outcome: ${outcome}`, + `- ${difficultyNote(difficulty)}`, + "", + toneInstruction(tone), + "", + "Hard requirements:", + "- MAXIMUM 260 characters total, including any hashtags. (Hard X limit is 280; the 20-character buffer is for safety.)", + "- The FIRST sentence is the hook — the single most interesting or surprising thing about this event. Make someone stop scrolling.", + "- The hook MUST NOT start with the words \"I\", \"We\", or \"Just\". Lead with the OUTCOME or the PROBLEM instead.", + "- After the hook, at most 1–2 short follow-up lines that add context. Cut anything that doesn't earn its characters.", + "- 0–2 hashtags only. They must be relevant and specific (good: #buildinpublic, #webdev, #postgres). NEVER use generic spam tags like #coding, #developer, #tech, #programming, #software.", + "- No emojis unless the tone is casual; even then, at most one.", + "- No hype openers (\"Excited to share\", \"Thrilled to announce\", \"Big news\", etc.).", + "- Single post — never a thread.", + "- No links unless they appear verbatim in the source material.", + "", + "Self-check before responding:", + "- Count the characters of your draft. If the total is over 260, rewrite it shorter and count again. Do this until you are at or under 260.", + "- Verify the first word is NOT \"I\", \"We\", or \"Just\". If it is, rewrite the hook to lead with the outcome or the problem.", + "", + "Output format:", + "- Return ONLY the post text.", + "- No explanation, no character count, no preamble, no surrounding quotation marks, no Markdown code fences.", + ].join("\n"); +} diff --git a/src/lib/postGenerator/withRetry.ts b/src/lib/postGenerator/withRetry.ts new file mode 100644 index 0000000..a84a801 --- /dev/null +++ b/src/lib/postGenerator/withRetry.ts @@ -0,0 +1,28 @@ +const RETRY_DELAY_MS = 500; + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function withRetry<T>( + fn: () => Promise<T>, + retries: number = 1, + label: string = "", +): Promise<T | null> { + let attempt = 0; + while (true) { + try { + return await fn(); + } catch (err) { + if (attempt < retries) { + attempt++; + await sleep(RETRY_DELAY_MS); + continue; + } + const message = err instanceof Error ? err.message : String(err); + const prefix = label ? `[withRetry:${label}]` : "[withRetry]"; + console.error(`${prefix} failed after ${retries + 1} attempt(s): ${message}`); + return null; + } + } +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..b1ad4a3 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: + process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], + }); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/src/lib/scrollReveal.ts b/src/lib/scrollReveal.ts new file mode 100644 index 0000000..45d498a --- /dev/null +++ b/src/lib/scrollReveal.ts @@ -0,0 +1,18 @@ +export function initScrollReveal() { + const elements = document.querySelectorAll(".reveal"); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("in-view"); + // One-way latch: stays in-view once triggered + } + }); + }, + { threshold: 0.12, rootMargin: "0px 0px -40px 0px" }, + ); + + elements.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); +} diff --git a/src/lib/timeline/constellationUtils.ts b/src/lib/timeline/constellationUtils.ts new file mode 100644 index 0000000..9e1262a --- /dev/null +++ b/src/lib/timeline/constellationUtils.ts @@ -0,0 +1,206 @@ +import type { TimelineEntry } from "@/features/timeline/types"; + +export type StarSize = "small" | "medium" | "large"; + +export type StarColor = { + core: string; + glow: string; + trail: string; +}; + +export type ConstellationStar = { + entry: TimelineEntry; + size: StarSize; + color: StarColor; + /** 0–100 percentage along the path */ + x: number; + /** 0–100 vertical offset for the wave */ + y: number; + /** ISO week string e.g. `"2026-W12"` */ + clusterId: string; +}; + +export type StarCluster = { + id: string; + label: string; + stars: ConstellationStar[]; + centerX: number; +}; + +/** + * Visual/event discriminator for constellation styling. + * + * `TimelineEntry` uses field `type` with `"pr"` for pull requests; constellation + * keys match conventional GitHub-style `"pull_request"`. Use {@link toConstellationEventType}. + */ +export type TimelineEntryEventType = + | "commit" + | "pull_request" + | "release" + | "milestone"; + +export function toConstellationEventType( + entry: TimelineEntry, +): Exclude<TimelineEntryEventType, "milestone"> { + return entry.type === "pr" ? "pull_request" : entry.type; +} + +/** Maps event type to color scheme */ +export function getStarColor(eventType: TimelineEntryEventType): StarColor { + switch (eventType) { + case "commit": + return { + core: "#c4b5fd", + glow: "rgba(196,181,253,0.4)", + trail: "rgba(196,181,253,0.15)", + }; + case "pull_request": + return { + core: "#a78bfa", + glow: "rgba(167,139,250,0.5)", + trail: "rgba(167,139,250,0.2)", + }; + case "release": + return { + core: "#fbbf24", + glow: "rgba(251,191,36,0.5)", + trail: "rgba(251,191,36,0.2)", + }; + case "milestone": + return { + core: "#34d399", + glow: "rgba(52,211,153,0.5)", + trail: "rgba(52,211,153,0.2)", + }; + default: + return { + core: "#94a3b8", + glow: "rgba(148,163,184,0.3)", + trail: "rgba(148,163,184,0.1)", + }; + } +} + +/** Maps event type to star size */ +export function getStarSize(eventType: TimelineEntryEventType): StarSize { + if (eventType === "release" || eventType === "milestone") return "large"; + if (eventType === "pull_request") return "medium"; + return "small"; +} + +/** Maps StarSize to pixel radius */ +export function getStarRadius(size: StarSize): number { + if (size === "large") return 10; + if (size === "medium") return 7; + return 4.5; +} + +function mondayOfISOWeekYear(isoWeekYear: number): Date { + const jan4 = new Date(isoWeekYear, 0, 4); + const offset = (jan4.getDay() + 6) % 7; + const mondayWeek1 = new Date(jan4); + mondayWeek1.setDate(jan4.getDate() - offset); + return mondayWeek1; +} + +/** + * ISO 8601 week id `YYYY-Www` (Monday week start; week 1 contains Jan 4). + * Uses the nearest-Thursday method (week-year may differ from calendar year near Jan 1). + */ +export function getISOWeek(dateStr: string): string { + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return "1970-W01"; + + const local = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const dayNr = (local.getDay() + 6) % 7; + local.setDate(local.getDate() - dayNr + 3); + + const firstThursday = local.getTime(); + + local.setMonth(0, 1); + if (local.getDay() !== 4) { + local.setMonth(0, 1 + ((4 - local.getDay() + 7) % 7)); + } + const week1Thursday = local.getTime(); + + let week = 1 + Math.round((firstThursday - week1Thursday) / 604800000); + const isoWeekYear = new Date(firstThursday).getFullYear(); + + if (week < 1) { + return getISOWeek(new Date(isoWeekYear - 1, 11, 28).toISOString()); + } + + return `${isoWeekYear}-W${String(week).padStart(2, "0")}`; +} + +/** Parses `YYYY-Www` and returns `"Week of Mon DD"` for the Monday of that ISO week-year */ +export function formatClusterLabel(isoWeek: string): string { + const match = /^(\d{4})-W(\d{2})$/.exec(isoWeek.trim()); + if (!match) return isoWeek; + + const isoWeekYear = parseInt(match[1], 10); + const week = parseInt(match[2], 10); + if (!Number.isFinite(isoWeekYear) || !Number.isFinite(week)) return isoWeek; + + const week1Monday = mondayOfISOWeekYear(isoWeekYear); + const monday = new Date(week1Monday); + monday.setDate(week1Monday.getDate() + (week - 1) * 7); + + return `Week of ${monday.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}`; +} + +export function buildConstellationStars(entries: TimelineEntry[]): { + stars: ConstellationStar[]; + clusters: StarCluster[]; +} { + if (entries.length === 0) return { stars: [], clusters: [] }; + + const sorted = [...entries].sort( + (a, b) => new Date(a.dateIso).getTime() - new Date(b.dateIso).getTime(), + ); + + const t0 = new Date(sorted[0].dateIso).getTime(); + const t1 = new Date(sorted[sorted.length - 1].dateIso).getTime(); + const totalTimespan = t1 - t0 || 1; + + const stars: ConstellationStar[] = sorted.map((entry, i) => { + const t = (new Date(entry.dateIso).getTime() - t0) / totalTimespan; + const x = 5 + t * 90; + const eventType = toConstellationEventType(entry); + const y = + 50 + + Math.sin(t * Math.PI * 2.5) * 22 + + Math.sin(i * 1.7) * 8; + + return { + entry, + size: getStarSize(eventType), + color: getStarColor(eventType), + x, + y: Math.max(15, Math.min(85, y)), + clusterId: getISOWeek(entry.dateIso), + }; + }); + + const clusterMap = new Map<string, ConstellationStar[]>(); + for (const star of stars) { + const bucket = clusterMap.get(star.clusterId); + if (bucket) bucket.push(star); + else clusterMap.set(star.clusterId, [star]); + } + + const clusters: StarCluster[] = Array.from(clusterMap.entries()).map( + ([id, clusterStars]) => ({ + id, + label: formatClusterLabel(id), + stars: clusterStars, + centerX: + clusterStars.reduce((sum, s) => sum + s.x, 0) / clusterStars.length, + }), + ); + + return { stars, clusters }; +} diff --git a/src/server/github/buildGithubConnectionStatus.ts b/src/server/github/buildGithubConnectionStatus.ts new file mode 100644 index 0000000..cc6f2d9 --- /dev/null +++ b/src/server/github/buildGithubConnectionStatus.ts @@ -0,0 +1,30 @@ +import type { Session } from "next-auth"; +import type { GitHubConnectionStatus } from "@/lib/github/types"; +import { prisma } from "@/lib/prisma"; +import { getGithubAccessTokenForUser } from "./getGithubAccessToken"; + +export async function buildGithubConnectionStatus( + session: Session, +): Promise<GitHubConnectionStatus> { + const userId = session.user?.id; + if (!userId) { + return { connected: false, user: null, selectedRepo: null }; + } + + const token = await getGithubAccessTokenForUser(userId); + const account = await prisma.account.findFirst({ + where: { userId, provider: "github" }, + select: { providerAccountId: true }, + }); + + return { + connected: !!token, + user: account + ? { + login: account.providerAccountId, + avatarUrl: session.user?.image ?? "", + } + : null, + selectedRepo: session.user?.selectedGithubRepo ?? null, + }; +} diff --git a/src/server/github/getGithubAccessToken.ts b/src/server/github/getGithubAccessToken.ts new file mode 100644 index 0000000..fc1d4d2 --- /dev/null +++ b/src/server/github/getGithubAccessToken.ts @@ -0,0 +1,11 @@ +import { prisma } from "@/lib/prisma"; + +export async function getGithubAccessTokenForUser( + userId: string, +): Promise<string | null> { + const account = await prisma.account.findFirst({ + where: { userId, provider: "github" }, + select: { access_token: true }, + }); + return account?.access_token ?? null; +} diff --git a/src/server/timeline/fetchTimelineEntries.ts b/src/server/timeline/fetchTimelineEntries.ts new file mode 100644 index 0000000..9b97b31 --- /dev/null +++ b/src/server/timeline/fetchTimelineEntries.ts @@ -0,0 +1,78 @@ +/** + * Shared server-side function used by both the /timeline page (direct call) + * and the GET /api/timeline route handler. + * + * Calling this directly from the page avoids the Next.js App Router + * self-fetch deadlock, where a server component fetch() to its own route + * handler hangs indefinitely during SSR. + */ + +import { prisma } from "@/lib/prisma"; +import { getGithubAccessTokenForUser } from "@/server/github/getGithubAccessToken"; +import { + fetchRepoCommits, + fetchRepoPullRequests, + fetchRepoReleases, +} from "@/lib/github/api"; +import { + normalizeCommit, + normalizePullRequest, + normalizeRelease, +} from "@/lib/github/normalizeEvents"; +import type { TimelineEntry } from "@/features/timeline/types"; + +export type FetchTimelineResult = + | { ok: true; entries: TimelineEntry[] } + | { ok: false; reason: "unauthenticated" | "no_token" | "no_repo" | "fetch_error"; message: string }; + +export async function fetchTimelineEntries( + userId: string, +): Promise<FetchTimelineResult> { + const token = await getGithubAccessTokenForUser(userId); + if (!token) { + return { + ok: false, + reason: "no_token", + message: "No GitHub token on file. Sign in again with GitHub.", + }; + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { selectedGithubRepo: true }, + }); + + const selected = user?.selectedGithubRepo ?? null; + if (!selected) { + return { ok: true, entries: [] }; + } + + const [owner, repo] = selected.split("/", 2); + if (!owner || !repo) { + return { + ok: false, + reason: "no_repo", + message: `Invalid repo format: "${selected}". Expected "owner/repo".`, + }; + } + + try { + const [commits, pulls, releases] = await Promise.all([ + fetchRepoCommits(token, owner, repo), + fetchRepoPullRequests(token, owner, repo), + fetchRepoReleases(token, owner, repo), + ]); + + const repoName = `${owner}/${repo}`; + const entries: TimelineEntry[] = [ + ...commits.map((c) => normalizeCommit(c, repoName)), + ...pulls.map((p) => normalizePullRequest(p, repoName)), + ...releases.map((r) => normalizeRelease(r, repoName)), + ].sort((a, b) => b.dateIso.localeCompare(a.dateIso)); + + return { ok: true, entries }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, reason: "fetch_error", message }; + } +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..2f89312 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,10 @@ +import type { DefaultSession } from "next-auth"; + +declare module "next-auth" { + interface Session { + user: DefaultSession["user"] & { + id: string; + selectedGithubRepo: string | null; + }; + } +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 1b2c2d7..4c9a3b4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -10,18 +10,31 @@ const config: Config = { ], theme: { extend: { - colors: { - background: "rgb(9 9 11)", - foreground: "rgb(244 244 245)", + fontFamily: { + sans: ["var(--font-geist-sans)", "system-ui", "sans-serif"], + mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"], }, - animation: { - "fade-in": "fade-in 240ms ease-out", + colors: { + accent: { + DEFAULT: "#8b5cf6", + hover: "#7c3aed", + muted: "rgba(139,92,246,0.12)", + foreground: "#ffffff", + }, }, keyframes: { "fade-in": { - "0%": { opacity: "0", transform: "translateY(6px)" }, - "100%": { opacity: "1", transform: "translateY(0)" }, + from: { opacity: "0" }, + to: { opacity: "1" }, }, + "slide-up": { + from: { transform: "translateY(10px)", opacity: "0" }, + to: { transform: "translateY(0)", opacity: "1" }, + }, + }, + animation: { + "fade-in": "fade-in 0.4s ease-out", + "slide-up": "slide-up 0.4s ease-out", }, }, }, diff --git a/tsconfig.json b/tsconfig.json index 2ff581a..96edb92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,11 +13,8 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "plugins": [ - { - "name": "next" - } - ], + "baseUrl": ".", + "plugins": [{ "name": "next" }], "baseUrl": ".", "paths": { "@/*": ["./src/*"]