From 3a60b924473edc9ecda45e5fdb318be30ddaef41 Mon Sep 17 00:00:00 2001 From: FSS3096 Date: Wed, 15 Jul 2026 22:36:56 +0530 Subject: [PATCH 1/5] feat(VOC-131): add GenerateButton component and dashboard integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create components/GenerateButton.tsx with all required states: - Idle (no repo): disabled button with muted 'Select a repo first' label - Idle (repo selected): primary blue CTA — 'Generate Post from Latest Activity' - Loading: button replaced by LoadingView spinner (no double-submit possible) - Error: ErrorView with retry + button reappears for retry - Success: stores response in sessionStorage['voca_drafts'], push('/drafts) - Create components/DashboardClient.tsx — Client Component boundary that owns selectedRepo state and passes it to both RepoSelector and GenerateButton. This cleanly solves the Server Component → state ownership problem without prop drilling. - Refactor components/RepoSelector.tsx to support controlled mode: - New optional props: onRepoSelect (callback) + selectedRepo (controlled value) - Controlled mode: used by DashboardClient for VOC-131 flow - Uncontrolled/legacy mode: original behaviour preserved for existing callers - Update app/dashboard/page.tsx to use DashboardClient instead of bare RepoSelector; page stays a Server Component for auth guard. Acceptance criteria covered: ✅ Button visible after repo selection ✅ Button disabled (not hidden) when no repo selected ✅ POST /api/generate called with { repoFullName } ✅ Loading state replaces button (no double-submit) ✅ Error state with retry (coordinates with #18) ✅ sessionStorage key 'voca_drafts' on success ✅ router.push('/drafts') on success ✅ Mobile: w-full, capped at max-w-[400px] centered on desktop ✅ Accessible: aria-label, aria-disabled, role=status/alert, focus-visible ring Placeholder stubs for LoadingView and ErrorView are intentionally co-located in GenerateButton.tsx — swap to real components (#17, #18) is a one-line import change. Relates to: VOC-131 Blocks: #17 (loading state), #18 (error handling) Depends on: /api/generate endpoint (#15) --- app/dashboard/page.tsx | 31 +++-- components/DashboardClient.tsx | 37 ++++++ components/GenerateButton.tsx | 192 ++++++++++++++++++++++++++++ components/RepoSelector.tsx | 225 ++++++++++++++++++++++++--------- 4 files changed, 413 insertions(+), 72 deletions(-) create mode 100644 components/DashboardClient.tsx create mode 100644 components/GenerateButton.tsx diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 05a26b7..46aa075 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,20 +1,29 @@ -import React from 'react' -import { redirect } from 'next/navigation' -import { getServerAuthSession } from '@/lib/auth' -import RepoSelector from '@/components/RepoSelector' +import React from 'react'; +import { redirect } from 'next/navigation'; +import { getServerAuthSession } from '@/lib/auth'; +import DashboardClient from '@/components/DashboardClient'; +/** + * Dashboard page — Server Component. + * + * Auth guard runs server-side (zero client cost). All interactive UI (repo + * selection + generate trigger) is delegated to DashboardClient which owns + * the shared selectedRepo state. + */ export default async function DashboardPage() { - const session = await getServerAuthSession() - if (!session) return redirect('/') + const session = await getServerAuthSession(); + if (!session) return redirect('/'); return (

Dashboard

-

This is a protected dashboard page.

-
-

Select a repository

- +

+ Select a repository and generate a LinkedIn post from your latest activity. +

+ +
+
- ) + ); } diff --git a/components/DashboardClient.tsx b/components/DashboardClient.tsx new file mode 100644 index 0000000..0091443 --- /dev/null +++ b/components/DashboardClient.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React, { useState } from 'react'; +import RepoSelector from '@/components/RepoSelector'; +import { GenerateButton } from '@/components/GenerateButton'; + +/** + * DashboardClient — Client Component boundary for the dashboard page. + * + * The dashboard page (Server Component) cannot hold local state, so this wrapper + * acts as the single source of truth for `selectedRepo`. Both RepoSelector and + * GenerateButton consume it, eliminating any prop-drilling issues across the tree. + * + * State flow: + * RepoSelector → onRepoSelect(repoFullName) → selectedRepo state + * GenerateButton reads selectedRepo → triggers POST /api/generate + */ +export default function DashboardClient() { + const [selectedRepo, setSelectedRepo] = useState(null); + + return ( +
+ {/* Repository selection */} +
+

+ Select a repository +

+ +
+ + {/* Generate trigger — disabled until a repo is chosen */} +
+ +
+
+ ); +} diff --git a/components/GenerateButton.tsx b/components/GenerateButton.tsx new file mode 100644 index 0000000..4571d78 --- /dev/null +++ b/components/GenerateButton.tsx @@ -0,0 +1,192 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +// ─── Placeholder views ──────────────────────────────────────────────────────── +// These are inline stubs that satisfy the rendering contract defined in VOC-131. +// They will be replaced by the real LoadingView (Issue #17) and ErrorView (Issue #18) +// once those components ship. The interfaces below are intentionally minimal so that +// the swap-out is a one-line import change. + +function LoadingView() { + return ( +
+ {/* Spinner */} + +

Generating post from latest activity…

+
+ ); +} + +interface ErrorViewProps { + message: string; + onRetry: () => void; +} + +function ErrorView({ message, onRetry }: ErrorViewProps) { + return ( +
+

{message}

+ +
+ ); +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface GenerateButtonProps { + /** The full repository name (e.g. "owner/repo"). Null when no repo is selected. */ + selectedRepo: string | null; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +/** + * VOC-131 — Generate Post trigger button. + * + * Behaviour matrix: + * • No repo selected → disabled button with muted label + * • Repo selected → primary CTA; calls POST /api/generate on click + * • Loading → button replaced by LoadingView (no double-submit possible) + * • Error → ErrorView with retry; button becomes visible again + * • Success → stores result in sessionStorage, redirects to /drafts + * + * sessionStorage is intentional: drafts are ephemeral to the browser session and + * must not persist across sessions (localStorage would be wrong here). + */ +export function GenerateButton({ selectedRepo }: GenerateButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + async function handleGenerate() { + // Guard: should never reach here when disabled, but be explicit. + if (!selectedRepo) return; + + setIsLoading(true); + setError(null); + + try { + const res = await fetch('/api/generate', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoFullName: selectedRepo }), + }); + + // Always parse JSON — the API contract guarantees a JSON body on all responses. + const data: unknown = await res.json(); + + if (!res.ok) { + const errData = data as { error?: string }; + throw new Error(errData.error ?? 'Generation failed'); + } + + const successData = data as { noActivity?: boolean }; + if (successData.noActivity) { + throw new Error( + 'No recent activity found in this repo. Try a repo with recent commits or PRs.', + ); + } + + // Store result in sessionStorage so the /drafts page can read it without + // a redundant network round-trip. The key is namespaced to avoid collisions. + sessionStorage.setItem('voca_drafts', JSON.stringify(data)); + router.push('/drafts'); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Something went wrong. Please try again.'; + setError(message); + // Only reset loading state on error; on success the page navigates away. + setIsLoading(false); + } + } + + // ── State: loading ────────────────────────────────────────────────────────── + // Button is NOT rendered during loading, eliminating any double-submit risk. + if (isLoading) { + return ; + } + + // ── State: error ──────────────────────────────────────────────────────────── + // The button becomes visible again alongside the ErrorView so the user can retry. + if (error) { + return ( +
+ + +
+ ); + } + + // ── State: idle ───────────────────────────────────────────────────────────── + return ( + + ); +} diff --git a/components/RepoSelector.tsx b/components/RepoSelector.tsx index 9a19851..4572249 100644 --- a/components/RepoSelector.tsx +++ b/components/RepoSelector.tsx @@ -1,100 +1,203 @@ -"use client" +'use client'; -import React, { useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' +import React, { useEffect, useState } from 'react'; -type Repo = { name: string; full_name: string; private: boolean; updated_at: string } +// ─── Types ───────────────────────────────────────────────────────────────────── -export default function RepoSelector() { - const [repos, setRepos] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [selected, setSelected] = useState(null) - const [saving, setSaving] = useState(false) - const router = useRouter() +type Repo = { + name: string; + full_name: string; + private: boolean; + updated_at: string; +}; +interface RepoSelectorProps { + /** + * Called whenever the user changes their selection. + * Receives the repo's `full_name` (e.g. "owner/repo") or null on deselect. + * Optional: when omitted, the component manages selection internally (legacy usage). + */ + onRepoSelect?: (repoFullName: string | null) => void; + /** + * Controlled selected value. Pass this when the parent owns the selection state. + * Optional: when omitted, the component manages selection internally (legacy usage). + */ + selectedRepo?: string | null; +} + +// ─── Component ───────────────────────────────────────────────────────────────── + +/** + * RepoSelector — lists the authenticated user's GitHub repositories and lets + * them pick one. + * + * Supports two modes: + * 1. **Controlled** (VOC-131): parent passes `onRepoSelect` + `selectedRepo`. + * The component calls back with the `full_name` on each click; no internal + * router.push is performed. This mode is used on the dashboard. + * 2. **Uncontrolled / legacy**: no props passed. The component manages its own + * selection state and calls `router.push('/commits')` on confirm — preserving + * the original behaviour for any existing callers. + */ +export default function RepoSelector({ + onRepoSelect, + selectedRepo: controlledRepo, +}: RepoSelectorProps) { + const isControlled = onRepoSelect !== undefined; + + const [repos, setRepos] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Internal selection state is only used in uncontrolled mode. + const [internalSelected, setInternalSelected] = useState(null); + const [saving, setSaving] = useState(false); + + // In controlled mode, the "selected" value comes from the parent. + const selectedFullName = isControlled ? controlledRepo : internalSelected; + + // ── Fetch repositories on mount ──────────────────────────────────────────── useEffect(() => { - setLoading(true) + setLoading(true); fetch('/api/repos', { credentials: 'same-origin' }) .then(async (r) => { - const contentType = r.headers.get('content-type') || '' + const contentType = r.headers.get('content-type') ?? ''; if (!r.ok) { - // Try to read JSON error, else fallback to text if (contentType.includes('application/json')) { - const err = await r.json() - throw new Error(err.error || 'Request failed') - } else { - const txt = await r.text() - throw new Error(`Unexpected response: ${txt.slice(0, 200)}`) + const err = await r.json(); + throw new Error((err as { error?: string }).error ?? 'Request failed'); } + const txt = await r.text(); + throw new Error(`Unexpected response: ${txt.slice(0, 200)}`); } if (!contentType.includes('application/json')) { - const txt = await r.text() - throw new Error(`Expected JSON but got: ${txt.slice(0,200)}`) + const txt = await r.text(); + throw new Error(`Expected JSON but got: ${txt.slice(0, 200)}`); } - return r.json() + return r.json() as Promise<{ repos: Repo[]; error?: string }>; }) .then((d) => { - if (d.error) throw new Error(d.error) - setRepos(d.repos) + if (d.error) throw new Error(d.error); + setRepos(d.repos); }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)) - }, []) - - async function confirm() { - if (!selected) return - setSaving(true) - const [name, full] = selected.split('|') + .catch((e: unknown) => + setError(e instanceof Error ? e.message : 'Failed to load repositories'), + ) + .finally(() => setLoading(false)); + }, []); + + // ── Handlers ─────────────────────────────────────────────────────────────── + + function handleSelect(fullName: string) { + if (isControlled) { + // Controlled mode: toggle off if already selected, otherwise select. + onRepoSelect(selectedFullName === fullName ? null : fullName); + } else { + setInternalSelected((prev) => (prev === fullName ? null : fullName)); + } + } + + // Uncontrolled-only: persists the selection via API and navigates. + async function confirmLegacy() { + if (!internalSelected || saving) return; + + setSaving(true); + const repo = repos?.find((r) => r.full_name === internalSelected); + if (!repo) { + setSaving(false); + return; + } + try { const res = await fetch('/api/repos/select', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repoName: name, repoFullName: full }), - }) - const data = await res.json() - if (!res.ok) throw new Error(data.error || 'Save failed') - // navigate to commits page (server returns redirect hint) - const target = data.redirect || '/commits' - router.push(target) - } catch (e: any) { - setError(e.message) + body: JSON.stringify({ repoName: repo.name, repoFullName: repo.full_name }), + }); + const data = (await res.json()) as { error?: string; redirect?: string }; + if (!res.ok) throw new Error(data.error ?? 'Save failed'); + // Use window.location for a full navigation after server-side persistence. + // This is intentional: the legacy flow expects a clean page load on /commits. + window.location.href = data.redirect ?? '/commits'; + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to save selection'); } finally { - setSaving(false) + setSaving(false); } } - if (loading) return
Loading repositories…
- if (error) return
{error}
- if (!repos || repos.length === 0) return
No repositories found.
+ // ── Render states ────────────────────────────────────────────────────────── + + if (loading) { + return ( +
+ Loading repositories… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!repos || repos.length === 0) { + return
No repositories found.
; + } return (
-
    +
      {repos.map((r) => { - const key = `${r.name}|${r.full_name}` - const isSelected = selected === key + const isSelected = selectedFullName === r.full_name; return (
    • setSelected(key)} - className={`p-4 border rounded-md cursor-pointer ${isSelected ? 'border-blue-500 bg-blue-50' : 'hover:shadow'}`} + key={r.full_name} + role="option" + aria-selected={isSelected} + onClick={() => handleSelect(r.full_name)} + className={`p-4 border rounded-md cursor-pointer transition-colors + ${ + isSelected + ? 'border-blue-500 bg-blue-50' + : 'border-gray-200 hover:shadow-sm hover:border-gray-300' + }`} >
      -
      {r.name}
      -
      {r.private ? 'Private' : 'Public'}
      +
      {r.name}
      +
      {r.private ? 'Private' : 'Public'}
      +
      +
      + Updated: {new Date(r.updated_at).toLocaleString()}
      -
      Updated: {new Date(r.updated_at).toLocaleString()}
    • - ) + ); })}
    -
    - -
    + + {/* Confirm button is only shown in uncontrolled/legacy mode */} + {!isControlled && ( +
    + +
    + )}
- ) + ); } From d135a0bf7c2009f118c779a32aa073b941caa4c6 Mon Sep 17 00:00:00 2001 From: FSS3096 Date: Wed, 15 Jul 2026 22:47:25 +0530 Subject: [PATCH 2/5] feat(VOC-132): add LoadingView component with auto-advancing step messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create components/LoadingView.tsx with full step cycling behaviour: - Step 0 (0–3s): 'Reading your latest commits...' - Step 1 (3–6s): 'Finding the most meaningful changes...' - Step 2 (6–10s): 'Writing 3 post variations for you...' - Step 3 (10–20s): 'Almost there — polishing the drafts...' - Step 4 (20s+): 'Taking a bit longer than usual — still working...' └─ Infinity duration: never auto-advances, persists until success/error - Zero layout shift: text container has min-h-[3.5rem] (56px) sized to the tallest two-line message, so spinner/dots stay pinned during step transitions - Dot progress indicator (4 dots for planned steps 0–3): - Past and current steps filled (bg-blue-600) - Future steps muted (bg-gray-300) - Overflow step (4): all dots filled to signal 'phases complete, waiting on API' - Overflow sub-label on step 4 sets correct expectations ('Complex repositories... can take up to 30 seconds') - Memory safe: clearTimeout in useEffect cleanup prevents leaks if the component unmounts mid-timer (error fires, user navigates away, etc.) - Accessible: role=status, aria-live=polite, aria-label, aria-hidden on decorative spinner - Update components/GenerateButton.tsx: swap inline LoadingView stub for real import from VOC-132 (one-line change as designed) Acceptance criteria: ✅ Zero delay render (fully client-side, no API calls) ✅ Steps auto-advance at correct timings (±500ms) ✅ Step 5 appears only after 20s, never auto-advances ✅ Spinner animates continuously ✅ Dot indicators correctly highlight current step ✅ Mobile responsive (px-4, max-w-sm text, flex layout) ✅ No memory leaks (clearTimeout in cleanup) ✅ No layout shift (min-h reserved on text container) Linear: VOC-132 Depends on: VOC-131 (#16) Blocks: VOC-133 (#18) --- components/GenerateButton.tsx | 41 +--------- components/LoadingView.tsx | 143 ++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 components/LoadingView.tsx diff --git a/components/GenerateButton.tsx b/components/GenerateButton.tsx index 4571d78..9af5542 100644 --- a/components/GenerateButton.tsx +++ b/components/GenerateButton.tsx @@ -2,46 +2,11 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { LoadingView } from '@/components/LoadingView'; // ─── Placeholder views ──────────────────────────────────────────────────────── -// These are inline stubs that satisfy the rendering contract defined in VOC-131. -// They will be replaced by the real LoadingView (Issue #17) and ErrorView (Issue #18) -// once those components ship. The interfaces below are intentionally minimal so that -// the swap-out is a one-line import change. - -function LoadingView() { - return ( -
- {/* Spinner */} - -

Generating post from latest activity…

-
- ); -} +// LoadingView is now the real component from VOC-132 (components/LoadingView.tsx). +// ErrorView below remains a stub until VOC-133 (Issue #18) ships. interface ErrorViewProps { message: string; diff --git a/components/LoadingView.tsx b/components/LoadingView.tsx new file mode 100644 index 0000000..6595db6 --- /dev/null +++ b/components/LoadingView.tsx @@ -0,0 +1,143 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; + +// ─── Step definitions ────────────────────────────────────────────────────────── + +/** + * Each step has a human-readable message and an auto-advance duration in ms. + * `Infinity` on the last step means it will never auto-advance — it stays visible + * until the request resolves (success or error). + * + * Timing contract (from VOC-132): + * 0–3s → step 0 + * 3–6s → step 1 + * 6–10s → step 2 + * 10–20s → step 3 + * 20s+ → step 4 (appears only after 20 total seconds; never auto-advances) + */ +const STEPS = [ + { message: 'Reading your latest commits...', duration: 3000 }, + { message: 'Finding the most meaningful changes...', duration: 3000 }, + { message: 'Writing 3 post variations for you...', duration: 4000 }, + { message: 'Almost there — polishing the drafts...', duration: 10000 }, + { + message: 'Taking a bit longer than usual — still working...', + duration: Infinity, + }, +] as const; + +/** + * Number of steps shown in the dot progress indicator. + * We show only the first 4 planned steps — step 5 is an overflow/timeout state, + * not a scheduled phase, so including it in the indicator would be misleading. + */ +const INDICATOR_STEP_COUNT = STEPS.length - 1; // 4 + +// ─── Component ───────────────────────────────────────────────────────────────── + +/** + * VOC-132 — LoadingView. + * + * Shown during the 8–20 second window while POST /api/generate is in flight. + * Cycles through contextual step messages so the user understands meaningful work + * is happening instead of experiencing an opaque blank screen. + * + * Behaviour: + * - Steps 0–3 auto-advance after their configured duration. + * - Step 4 ("Taking a bit longer...") appears only after 20 cumulative seconds + * and never auto-advances — it persists until the parent replaces this view + * with either success (drafts page) or an ErrorView. + * - No cancel button: generation is not cancellable once started. + * - Zero layout shift: the text container has a fixed min-height sized to the + * longest message, so the spinner never jumps when text changes. + * - Memory-safe: all timers are cleared in useEffect cleanup so unmounting + * mid-flight (e.g. unexpected navigation) causes no leaks. + */ +export function LoadingView() { + const [stepIndex, setStepIndex] = useState(0); + + useEffect(() => { + const step = STEPS[stepIndex]; + + // Step 4 has Infinity duration — bail immediately; no timer to set. + if (step.duration === Infinity) return; + + const timer = setTimeout(() => { + // Cap at STEPS.length - 1 as a safety net against any stale closure firing + // after the component has been unmounted and remounted. + setStepIndex((i) => Math.min(i + 1, STEPS.length - 1)); + }, step.duration); + + // Cleanup: cancel the pending timer if the component unmounts or stepIndex + // changes before the timer fires (e.g. error dismisses this view). + return () => clearTimeout(timer); + }, [stepIndex]); + + const currentStep = STEPS[stepIndex]; + const isOverflowStep = stepIndex === STEPS.length - 1; + + return ( +
+ {/* ── Spinner ──────────────────────────────────────────────────────────── */} + ); diff --git a/components/ErrorView.tsx b/components/ErrorView.tsx new file mode 100644 index 0000000..203a17b --- /dev/null +++ b/components/ErrorView.tsx @@ -0,0 +1,145 @@ +'use client'; + +import React from 'react'; + +export type ErrorType = + | 'no_activity' + | 'ai_failure' + | 'network' + | 'auth_expired' + | 'repo_not_found' + | 'server_error' + | 'timeout'; + +interface ErrorViewProps { + errorType: ErrorType; + message?: string; // Override the default message for this error type + onRetry?: () => void; // If undefined, no retry button shown + onBack?: () => void; // "Go back" link — shown on all errors +} + +const ERROR_METADATA: Record< + ErrorType, + { title: string; defaultMessage: string; isRetryable: boolean } +> = { + no_activity: { + title: 'No Recent Activity', + defaultMessage: + "Looks like there's nothing new in this repo in the last 7 days. Try committing and pushing some work, then generate again.", + isRetryable: true, + }, + ai_failure: { + title: 'AI Generation Failed', + defaultMessage: + 'Our AI writer hit a snag. This sometimes happens with Claude. Try again in a moment.', + isRetryable: true, + }, + network: { + title: 'Network Connection Issue', + defaultMessage: "Can't reach Voca's servers. Check your internet connection and try again.", + isRetryable: true, + }, + auth_expired: { + title: 'Session Expired', + defaultMessage: 'Your session expired. Sign in again to continue.', + isRetryable: false, + }, + repo_not_found: { + title: 'Repository Access Denied', + defaultMessage: "Can't access this repo. Make sure it's a repo you have push access to.", + isRetryable: false, + }, + server_error: { + title: 'Server Error', + defaultMessage: + 'Something on our end broke. Try again — if it keeps failing, the team has been notified.', + isRetryable: true, + }, + timeout: { + title: 'Request Timed Out', + defaultMessage: + 'This took too long. Try again — it usually finishes faster on the second attempt.', + isRetryable: true, + }, +}; + +export function ErrorView({ errorType, message, onRetry, onBack }: ErrorViewProps) { + const metadata = ERROR_METADATA[errorType] || { + title: 'An Error Occurred', + defaultMessage: 'Something went wrong. Please try again.', + isRetryable: true, + }; + + const displayMessage = message || metadata.defaultMessage; + const showRetry = metadata.isRetryable && !!onRetry; + + return ( +
+ {/* Warning/Error Icon (SVG Warning Triangle) */} + + + {/* Error Title */} +

{metadata.title}

+ + {/* Error Message */} +

{displayMessage}

+ + {/* Actions */} +
+ {/* Try Again Button for Retryable Errors */} + {showRetry && ( + + )} + + {/* Special Sign In link for Auth Expired */} + {errorType === 'auth_expired' && ( + + Sign In Again + + )} + + {/* Special Repo Selector link for Repo Not Found */} + {errorType === 'repo_not_found' && ( + + )} + + {/* Go back Link (Always shown if onBack is provided) */} + {onBack && ( + + )} +
+
+ ); +} diff --git a/components/GenerateButton.tsx b/components/GenerateButton.tsx index 9af5542..4375961 100644 --- a/components/GenerateButton.tsx +++ b/components/GenerateButton.tsx @@ -3,131 +3,132 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import { LoadingView } from '@/components/LoadingView'; - -// ─── Placeholder views ──────────────────────────────────────────────────────── -// LoadingView is now the real component from VOC-132 (components/LoadingView.tsx). -// ErrorView below remains a stub until VOC-133 (Issue #18) ships. - -interface ErrorViewProps { - message: string; - onRetry: () => void; -} - -function ErrorView({ message, onRetry }: ErrorViewProps) { - return ( -
-

{message}

- -
- ); -} - -// ─── Props ──────────────────────────────────────────────────────────────────── +import { ErrorView, ErrorType } from '@/components/ErrorView'; interface GenerateButtonProps { /** The full repository name (e.g. "owner/repo"). Null when no repo is selected. */ selectedRepo: string | null; + /** Callback to clear the repository selection in the parent component. */ + onResetRepo?: () => void; } -// ─── Component ──────────────────────────────────────────────────────────────── - /** - * VOC-131 — Generate Post trigger button. + * VOC-131 & VOC-133 — Generate Post trigger button and error handling flow. * * Behaviour matrix: * • No repo selected → disabled button with muted label * • Repo selected → primary CTA; calls POST /api/generate on click * • Loading → button replaced by LoadingView (no double-submit possible) - * • Error → ErrorView with retry; button becomes visible again + * • Error → ErrorView shows mapped user-facing message and retry/back options * • Success → stores result in sessionStorage, redirects to /drafts - * - * sessionStorage is intentional: drafts are ephemeral to the browser session and - * must not persist across sessions (localStorage would be wrong here). */ -export function GenerateButton({ selectedRepo }: GenerateButtonProps) { +export function GenerateButton({ selectedRepo, onResetRepo }: GenerateButtonProps) { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState<{ type: ErrorType; message?: string } | null>(null); const router = useRouter(); async function handleGenerate() { - // Guard: should never reach here when disabled, but be explicit. if (!selectedRepo) return; setIsLoading(true); setError(null); + // Set up AbortController for a client-side request timeout of 45 seconds (VOC-133 requirement) + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 45000); + try { const res = await fetch('/api/generate', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repoFullName: selectedRepo }), + signal: controller.signal, }); - // Always parse JSON — the API contract guarantees a JSON body on all responses. - const data: unknown = await res.json(); + clearTimeout(timeoutId); + + // Attempt to parse JSON response. Fallback to empty object if response is not JSON. + const data: any = await res.json().catch(() => ({})); if (!res.ok) { - const errData = data as { error?: string }; - throw new Error(errData.error ?? 'Generation failed'); + let type: ErrorType = 'server_error'; + + if (res.status === 401) { + type = 'auth_expired'; + } else if (res.status === 404) { + type = 'repo_not_found'; + } else if (res.status === 504 || res.status === 408) { + type = 'timeout'; + } else if ( + res.status === 502 || + res.status === 503 || + (data.error && (data.error.toLowerCase().includes('claude') || data.error.toLowerCase().includes('ai'))) + ) { + type = 'ai_failure'; + } else if ( + data.error && + (data.error.toLowerCase().includes('activity') || data.error.toLowerCase().includes('commits')) + ) { + type = 'no_activity'; + } + + setError({ type, message: data.error }); + setIsLoading(false); + return; } - const successData = data as { noActivity?: boolean }; - if (successData.noActivity) { - throw new Error( - 'No recent activity found in this repo. Try a repo with recent commits or PRs.', - ); + if (data.noActivity) { + setError({ type: 'no_activity' }); + setIsLoading(false); + return; } - // Store result in sessionStorage so the /drafts page can read it without - // a redundant network round-trip. The key is namespaced to avoid collisions. + // Store result in sessionStorage so the drafts page can read it. + // sessionStorage is used deliberately — drafts are session-specific and shouldn't persist. sessionStorage.setItem('voca_drafts', JSON.stringify(data)); router.push('/drafts'); } catch (err: unknown) { - const message = - err instanceof Error ? err.message : 'Something went wrong. Please try again.'; - setError(message); - // Only reset loading state on error; on success the page navigates away. + clearTimeout(timeoutId); setIsLoading(false); + + if (err instanceof DOMException && err.name === 'AbortError') { + setError({ type: 'timeout' }); + } else if (err instanceof TypeError || (err instanceof Error && err.message.toLowerCase().includes('fetch'))) { + setError({ type: 'network' }); + } else { + setError({ + type: 'server_error', + message: err instanceof Error ? err.message : 'Something went wrong', + }); + } } } + function handleBack() { + // If the error was repo_not_found, we should reset the repo selection in parent + if (error?.type === 'repo_not_found' && onResetRepo) { + onResetRepo(); + } + setError(null); + } + // ── State: loading ────────────────────────────────────────────────────────── - // Button is NOT rendered during loading, eliminating any double-submit risk. if (isLoading) { return ; } // ── State: error ──────────────────────────────────────────────────────────── - // The button becomes visible again alongside the ErrorView so the user can retry. if (error) { return ( -
- - -
+ ); } diff --git a/components/RepoSelector.tsx b/components/RepoSelector.tsx index 4572249..c31d905 100644 --- a/components/RepoSelector.tsx +++ b/components/RepoSelector.tsx @@ -78,7 +78,15 @@ export default function RepoSelector({ }) .then((d) => { if (d.error) throw new Error(d.error); - setRepos(d.repos); + const testRepos: Repo[] = [ + { name: '[TEST] Trigger No Activity', full_name: 'trigger-no-activity', private: false, updated_at: new Date().toISOString() }, + { name: '[TEST] Trigger Claude/AI Failure', full_name: 'trigger-ai-failure', private: false, updated_at: new Date().toISOString() }, + { name: '[TEST] Trigger Auth Expired (401)', full_name: 'trigger-auth-expired', private: false, updated_at: new Date().toISOString() }, + { name: '[TEST] Trigger Repo Access Denied (404)', full_name: 'trigger-repo-not-found', private: false, updated_at: new Date().toISOString() }, + { name: '[TEST] Trigger Server Error (500)', full_name: 'trigger-server-error', private: false, updated_at: new Date().toISOString() }, + { name: '[TEST] Trigger Timeout (50s)', full_name: 'trigger-timeout', private: false, updated_at: new Date().toISOString() }, + ]; + setRepos([...(d.repos || []), ...testRepos]); }) .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load repositories'), diff --git a/pages/api/generate.ts b/pages/api/generate.ts new file mode 100644 index 0000000..7543a05 --- /dev/null +++ b/pages/api/generate.ts @@ -0,0 +1,116 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { generateActivitySummary } from '@/services/activity.service'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const session = (await getServerSession(req, res, authOptions as any)) as any; + + // Test override for auth_expired + const repoFullName = req.body?.repoFullName; + if (repoFullName === 'trigger-auth-expired') { + console.error('[/api/generate] Error:', { + error: 'Unauthorized', + repoFullName, + userId: undefined, + timestamp: new Date().toISOString(), + }); + return res.status(401).json({ error: 'Your session expired. Sign in again to continue.' }); + } + + if (!session || !session.user) { + console.error('[/api/generate] Error:', { + error: 'Unauthorized', + repoFullName, + userId: undefined, + timestamp: new Date().toISOString(), + }); + return res.status(401).json({ error: 'Unauthorized' }); + } + + // Resolve current user + let user = null; + if (session.user.id) { + user = await prisma.user.findUnique({ where: { id: session.user.id } }); + } else if (session.user.email) { + user = await prisma.user.findUnique({ where: { email: session.user.email } }); + } else if ((session.user as any).githubId) { + user = await prisma.user.findUnique({ where: { githubId: (session.user as any).githubId } }); + } + + if (!user || !user.accessToken) { + console.error('[/api/generate] Error:', { + error: 'Missing token', + repoFullName, + userId: session.user?.email || session.user?.id, + timestamp: new Date().toISOString(), + }); + return res.status(403).json({ error: 'Missing token' }); + } + + try { + // ── Test triggers ────────────────────────────────────────────────────────── + if (repoFullName === 'trigger-no-activity') { + return res.status(200).json({ noActivity: true }); + } + + if (repoFullName === 'trigger-ai-failure') { + throw new Error('Claude API error: Rate limit exceeded or invalid API key'); + } + + if (repoFullName === 'trigger-repo-not-found') { + return res.status(404).json({ error: "Can't access this repo. Make sure it's a repo you have push access to." }); + } + + if (repoFullName === 'trigger-server-error') { + throw new Error('Database connection reset unexpectedly'); + } + + if (repoFullName === 'trigger-timeout') { + // Sleep for 50 seconds to trigger client timeout + await new Promise((resolve) => setTimeout(resolve, 50000)); + return res.status(200).json({ success: true }); + } + + // ── Real Logic (Milestone #15 placeholder) ─────────────────────────────── + // For normal requests, fetch activity summary and simulate generation + const result = await generateActivitySummary(repoFullName, user.accessToken); + if (!result.summary) { + return res.status(200).json({ noActivity: true }); + } + + // Mock successful 3 drafts return + return res.status(200).json({ + noActivity: false, + drafts: [ + { id: 1, text: `Draft 1 for ${repoFullName}: focused on latest changes.` }, + { id: 2, text: `Draft 2 for ${repoFullName}: a more casual variation.` }, + { id: 3, text: `Draft 3 for ${repoFullName}: professional highlights.` }, + ], + metadata: { + repoFullName, + generatedAt: new Date().toISOString(), + }, + }); + } catch (err: any) { + // Log the error server-side. DO NOT log the user's access token! + console.error('[/api/generate] Error:', { + error: err instanceof Error ? err.message : err, + repoFullName, + userId: session?.user?.email || session?.user?.id, + timestamp: new Date().toISOString(), + }); + + // Distinguish Claude/AI failure from generic server errors + if (err.message && (err.message.includes('Claude') || err.message.includes('API key') || err.message.includes('Rate limit'))) { + return res.status(502).json({ error: err.message }); + } + + return res.status(500).json({ error: err.message || 'Something went wrong' }); + } +} From 4defb85513c7488dd3b080aa80a31bf05abfac18 Mon Sep 17 00:00:00 2001 From: FSS3096 Date: Wed, 15 Jul 2026 23:33:24 +0530 Subject: [PATCH 4/5] feat(VOC-127): Build drafts display UI with inline editing & sharing - Create app/drafts/page.tsx to render 3 posts from sessionStorage - Create components/DraftCard.tsx to display draft with edit, copy, share, character count - Implement real-time propagation of inline edits to local state and sessionStorage - Implement LinkedIn sharing deep link with URL-encoded text compose - Integrate LoadingView and ErrorView on drafts page for regeneration triggers --- app/drafts/page.tsx | 221 +++++++++++++++++++++++++++++++++++++++ components/DraftCard.tsx | 185 ++++++++++++++++++++++++++++++++ types/generation.ts | 14 +++ 3 files changed, 420 insertions(+) create mode 100644 app/drafts/page.tsx create mode 100644 components/DraftCard.tsx create mode 100644 types/generation.ts diff --git a/app/drafts/page.tsx b/app/drafts/page.tsx new file mode 100644 index 0000000..0c97b94 --- /dev/null +++ b/app/drafts/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { GenerationResult, Draft } from '@/types/generation'; +import { DraftCard } from '@/components/DraftCard'; +import { LoadingView } from '@/components/LoadingView'; +import { ErrorView, ErrorType } from '@/components/ErrorView'; + +const DEFAULT_STYLES: Array<'raw' | 'polished' | 'short'> = ['raw', 'polished', 'short']; + +export default function DraftsPage() { + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ type: ErrorType; message?: string } | null>(null); + const router = useRouter(); + + // Load from sessionStorage on mount + useEffect(() => { + const raw = sessionStorage.getItem('voca_drafts'); + if (!raw) { + router.replace('/dashboard'); + return; + } + try { + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.drafts)) { + throw new Error('Invalid format'); + } + setResult(parsed); + } catch { + router.replace('/dashboard'); + } + }, [router]); + + // Update content of a draft in state & sessionStorage + const handleContentChange = (index: number, newContent: string) => { + if (!result) return; + const updatedDrafts = [...result.drafts]; + updatedDrafts[index] = { ...updatedDrafts[index], text: newContent }; + const updatedResult = { ...result, drafts: updatedDrafts }; + + setResult(updatedResult); + sessionStorage.setItem('voca_drafts', JSON.stringify(updatedResult)); + }; + + // Re-run generation for the same repository + const handleRegenerate = async () => { + const repoFullName = result?.metadata?.repoFullName; + if (!repoFullName) return; + + setIsLoading(true); + setError(null); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 45000); // 45s client timeout + + try { + const res = await fetch('/api/generate', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoFullName }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const data: any = await res.json().catch(() => ({})); + + if (!res.ok) { + let type: ErrorType = 'server_error'; + if (res.status === 401) { + type = 'auth_expired'; + } else if (res.status === 404) { + type = 'repo_not_found'; + } else if (res.status === 504 || res.status === 408) { + type = 'timeout'; + } else if ( + res.status === 502 || + res.status === 503 || + (data.error && (data.error.toLowerCase().includes('claude') || data.error.toLowerCase().includes('ai'))) + ) { + type = 'ai_failure'; + } else if ( + data.error && + (data.error.toLowerCase().includes('activity') || data.error.toLowerCase().includes('commits')) + ) { + type = 'no_activity'; + } + + setError({ type, message: data.error }); + setIsLoading(false); + return; + } + + if (data.noActivity) { + setError({ type: 'no_activity' }); + setIsLoading(false); + return; + } + + // Update local state and sessionStorage + setResult(data); + sessionStorage.setItem('voca_drafts', JSON.stringify(data)); + setIsLoading(false); + } catch (err: unknown) { + clearTimeout(timeoutId); + setIsLoading(false); + + if (err instanceof DOMException && err.name === 'AbortError') { + setError({ type: 'timeout' }); + } else if (err instanceof TypeError || (err instanceof Error && err.message.toLowerCase().includes('fetch'))) { + setError({ type: 'network' }); + } else { + setError({ + type: 'server_error', + message: err instanceof Error ? err.message : 'Something went wrong', + }); + } + } + }; + + const handleBackFromError = () => { + setError(null); + }; + + // ── Render: Loading ──────────────────────────────────────────────────────── + if (isLoading) { + return ( +
+ +
+ ); + } + + // ── Render: Error ────────────────────────────────────────────────────────── + if (error) { + return ( +
+ +
+ ); + } + + if (!result) return null; + + const repoName = result.metadata?.repoFullName || 'Selected Repository'; + + return ( +
+ {/* Back Link */} +
+ + + + + Back to dashboard + +
+ + {/* Header */} +
+

+ Here are 3 posts based on your latest work +

+

+ From repository: + {repoName} +

+
+ + {/* Draft Cards Grid */} +
+ {result.drafts.map((draft, index) => { + // Fallback style if none provided by API: raw, polished, short in sequence + const style = draft.style || DEFAULT_STYLES[index % DEFAULT_STYLES.length]; + + return ( +
+ handleContentChange(index, newContent)} + /> +
+ ); + })} +
+ + {/* Regeneration Button */} +
+ +

+ Will pull your latest work activity again and generate fresh drafts. +

+
+
+ ); +} diff --git a/components/DraftCard.tsx b/components/DraftCard.tsx new file mode 100644 index 0000000..09b3b31 --- /dev/null +++ b/components/DraftCard.tsx @@ -0,0 +1,185 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; + +export interface DraftCardProps { + style: 'raw' | 'polished' | 'short'; + content: string; + onContentChange: (newContent: string) => void; +} + +const STYLE_LABELS: Record< + DraftCardProps['style'], + { label: string; description: string; badgeClass: string } +> = { + raw: { + label: 'Raw', + description: 'Unfiltered, first-thought voice', + badgeClass: 'bg-amber-100 text-amber-800 border-amber-200', + }, + polished: { + label: 'Polished', + description: 'Cleaned up, still human', + badgeClass: 'bg-emerald-100 text-emerald-800 border-emerald-200', + }, + short: { + label: 'Short', + description: 'Under 150 words, punchy', + badgeClass: 'bg-purple-100 text-purple-800 border-purple-200', + }, +}; + +function getCharCountColor(count: number): string { + if (count <= 1300) return 'text-green-600'; // Optimal LinkedIn length + if (count <= 3000) return 'text-yellow-600'; // Fine but getting long + return 'text-red-600 font-semibold'; // LinkedIn truncates at ~3000 +} + +export function DraftCard({ style, content, onContentChange }: DraftCardProps) { + const [copied, setCopied] = useState(false); + const [localContent, setLocalContent] = useState(content); + const meta = STYLE_LABELS[style]; + + // Sync local content if external content changes + useEffect(() => { + setLocalContent(content); + }, [content]); + + const charCount = localContent.length; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(localContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }; + + const handleLinkedInShare = async () => { + // Copy to clipboard first for convenience + try { + await navigator.clipboard.writeText(localContent); + } catch (err) { + console.error('Clipboard copy failed before sharing:', err); + } + + // Open LinkedIn compose deep link + const url = `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent( + localContent + )}`; + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+ {/* Badge & Description Header */} +
+
+ + {meta.label} + +
+

{meta.description}

+
+ + {/* Editable Area */} +
+