diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md index ff5978d..289b901 100644 --- a/docs/relaunch/DECISIONS.md +++ b/docs/relaunch/DECISIONS.md @@ -31,6 +31,11 @@ Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`. - 2026-10-27 · WP-08 · Quota consume = check-then-mark (not atomic) in `assertAndConsumeQuota` + `consumeEpisodeQuota` · a workspace has a single owner (§3: no teams in the MVP), so a race can only double-count the owner against themselves; the pipeline routes (WP-05/06) call these around POST /api/episodes · a transactional `INSERT … WHERE count < limit` (needs a CTE on every create for no real contention) - 2026-10-27 · WP-08 · Billing page renders as a partial-prerender shell with the plan card streamed in ``; the quota reset date is computed at request time · Next 16 `cacheComponents` forbids route segment `dynamic` and blocks `new Date()` during prerender; the streaming shell keeps the instant preview · `export const instant = false` (newer, less documented escape hatch) +- 2026-10-27 · WP-06 · E2E run against an embedded Postgres (PGlite) behind `DATABASE_URL=pglite:` + `scripts/e2e-seed.mjs`; tests select it with `E2E_DB=1` (`pnpm e2e:db`), keyless suite unchanged · real U6/U11–U13 flows need a real DB; no Neon instance exists (owner blocker) · ephemeral docker postgres (no docker in CI), mocking (not e2e) +- 2026-10-27 · WP-06 · E2E auth seam: `E2E_AUTH_USER_ID` honored only when Clerk is absent AND DATABASE_URL starts with `pglite:` — it can never authenticate against a real database (WP-02's fail-closed rule preserved) · e2e needs an identity without Clerk keys · any-env preview user (weakens fail-closed) +- 2026-10-27 · WP-06 · PGlite kept as one instance per dataDir via globalThis (Turbopack loads db modules per route chunk; a second instance on the same dir aborts the wasm) and `serverExternalPackages` so its wasm loader stays unbundled; `replaceSegments` falls back to sequential statements without neon's `.batch` · observed crashes; documented PGlite+bundler guidance · per-chunk instances +- 2026-10-27 · WP-06 · `cacheComponents`/`partialPrefetching` disabled app-wide · the PPR runtime replay guard rejects `Date.now()` in streamed per-user pages (intermittent second-request failures); every page already uses `connection()` + Suspense which renders identically without it · per-route workarounds (fragile, guard fired from replayed shells) +- 2026-10-27 · WP-06 · Stored segment ids namespaced `seg__` — segments.id is the GLOBAL primary key (§7.1), so per-episode `seg_000000` collided across episodes (would break the 2nd episode in production) · found by the DB-mode e2e · uuid ids (breaks §7.3 prompt-shape familiarity) - 2026-10-27 · WP-01 · `/dev/components` ships in every build; "dev only" is enforced by noindex metadata + zero inbound links (no nav/sitemap entry) · WP-00 fixed `pnpm e2e` to test the production build via `next start`, so a NODE_ENV or env-var gate would break the axe e2e locally and in CI · gating the route on `NODE_ENV=development`; a `NEXT_PUBLIC_*` flag set in the CI e2e step - 2026-10-27 · WP-01 · Toast = shadcn `sonner` with the Toaster pinned bottom-right + 4 s (§5.3); dropped the generated `next-themes` wiring, sonner `theme="system"` follows prefers-color-scheme like every other component · the registry has no radix `toast` for the radix style ("only available for Base UI projects" per CLI) and sonner is shadcn's current default · hand-rolled wrapper on radix Toast primitives - 2026-10-27 · WP-01 · shadcn `--muted-foreground` maps to §5.2 `--body`, not `--faint`: faint on surface-2/3 sits at/below the 4.5:1 AA floor (4.51:1 / ~4.2:1) and muted-foreground renders on muted surfaces (dialog footers, segmented tabs); faint remains in use for text on bg/surface (4.83:1) via custom components · acceptance says fix the component mapping, not the spec tokens · mapping muted-foreground to faint and flagging the AA miss in the PR diff --git a/web/.gitignore b/web/.gitignore index 5f82093..f2581be 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -45,3 +45,6 @@ next-env.d.ts test-results/ playwright-report/ blob-report/ + +# e2e +.pglite-e2e/ diff --git a/web/app/(app)/app/episodes/[id]/page.tsx b/web/app/(app)/app/episodes/[id]/page.tsx new file mode 100644 index 0000000..b867634 --- /dev/null +++ b/web/app/(app)/app/episodes/[id]/page.tsx @@ -0,0 +1,48 @@ +// MK-5/MK-6: processing and workspace share this URL; the client view drives +// the pipeline and polls until terminal (§7.5). Dynamic per-user data is +// streamed behind Suspense (Next 16 cacheComponents). +import { Suspense } from "react"; +import { connection } from "next/server"; +import Link from "next/link"; +import { requireUser } from "@/lib/auth"; +import { getShowByOwner } from "@/db/queries"; +import { handleEpisodeWithArtifactsGet } from "@/lib/pipeline/service"; +import { WorkspaceView } from "@/components/workspace/workspace-view"; + +export default function EpisodeWorkspacePage({ params }: PageProps<"/app/episodes/[id]">) { + return ( + + Loading your workspace… + + } + > + + + ); +} + +async function WorkspaceContent({ params }: { params: Promise<{ id: string }> }) { + // Per-user episode data — request-time rendering. + await connection(); + const userId = await requireUser(); + const { id } = await params; + + const result = await handleEpisodeWithArtifactsGet(userId, id); + if (result.status !== 200) { + return ( +
+

Episode not found

+ + Back to episodes + +
+ ); + } + + const data = result.json as unknown as import("@/lib/pipeline/client").WorkspaceData; + const show = await getShowByOwner(userId); + + return ; +} diff --git a/web/app/api/artifacts/[id]/route.ts b/web/app/api/artifacts/[id]/route.ts new file mode 100644 index 0000000..fcf8d3f --- /dev/null +++ b/web/app/api/artifacts/[id]/route.ts @@ -0,0 +1,15 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleArtifactPatch } from "@/lib/pipeline/service"; + +/** PATCH /api/artifacts/:id — {content} save · {ready} · {undo:true} (§7.5). */ +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const body: unknown = await req.json().catch(() => ({})); + const result = await handleArtifactPatch(userId, id, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/analyze/route.ts b/web/app/api/episodes/[id]/analyze/route.ts new file mode 100644 index 0000000..2e82e0e --- /dev/null +++ b/web/app/api/episodes/[id]/analyze/route.ts @@ -0,0 +1,14 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleAnalyzePost } from "@/lib/pipeline/service"; + +/** POST /api/episodes/:id/analyze — chapters/topics/candidate quotes (§7.5). */ +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleAnalyzePost(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts b/web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts new file mode 100644 index 0000000..7408a5a --- /dev/null +++ b/web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts @@ -0,0 +1,17 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleArtifactGeneratePost } from "@/lib/pipeline/service"; + +export const maxDuration = 300; + +/** POST /api/episodes/:id/artifacts/:kind/generate — one artifact (§7.5). */ +export async function POST(req: Request, { params }: { params: Promise<{ id: string; kind: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id, kind } = await params; + const body: unknown = await req.json().catch(() => ({})); + const result = await handleArtifactGeneratePost(userId, id, kind, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/route.ts b/web/app/api/episodes/[id]/route.ts index 94496b7..bb3b1dd 100644 --- a/web/app/api/episodes/[id]/route.ts +++ b/web/app/api/episodes/[id]/route.ts @@ -1,6 +1,7 @@ import { getAuthUserId } from "@/lib/auth"; import { AUTH_REQUIRED_COPY } from "@/lib/copy"; import { handleEpisodeGet } from "@/lib/episodes/service"; +import { handleEpisodeDelete } from "@/lib/pipeline/service"; /** GET /api/episodes/:id — episode + status, polled every 2 s (§7.5, U4). */ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { @@ -12,3 +13,14 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str const result = await handleEpisodeGet(userId, id); return Response.json(result.json, { status: result.status }); } + +/** DELETE /api/episodes/:id — edit meta handled by PATCH; delete + blob (§7.5). */ +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleEpisodeDelete(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/workspace/route.ts b/web/app/api/episodes/[id]/workspace/route.ts new file mode 100644 index 0000000..394906f --- /dev/null +++ b/web/app/api/episodes/[id]/workspace/route.ts @@ -0,0 +1,15 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleEpisodeWithArtifactsGet } from "@/lib/pipeline/service"; + +/** GET /api/episodes/:id/workspace — episode + artifacts + analysis + segments + * for the workspace view; polled while not terminal (§7.5). */ +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleEpisodeWithArtifactsGet(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/components/workspace/editors.tsx b/web/components/workspace/editors.tsx new file mode 100644 index 0000000..e0f4ddb --- /dev/null +++ b/web/components/workspace/editors.tsx @@ -0,0 +1,367 @@ +"use client"; +// MK-6b artifact layouts: every field an auto-growing textarea (Markdown +// allowed where §7.2 says so), autosave 1.5 s after the last keystroke, +// char/word counters with limits. One component per kind so hooks stay +// unconditional; the parent remounts editors on regenerate via `key`. +import { useEffect, useRef, useState } from "react"; +import type { ArtifactKindName, ArtifactView, TranscriptSegment } from "@/lib/pipeline/client"; +import { renderBlogBody } from "@/lib/export/format"; + +function mmss(ms: number): string { + const s = Math.floor(ms / 1000); + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +function EditorShell({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** Auto-growing textarea with the 1.5 s debounced autosave (M6). */ +function AutoField({ + value, + onChange, + onSave, + label, + max, + words, + rows = 3, + markdown, + testId, +}: { + value: string; + onChange: (v: string) => void; + onSave: () => void; + label: string; + max?: number; + words?: [number, number]; + rows?: number; + markdown?: boolean; + testId?: string; +}) { + const ref = useRef(null); + const timer = useRef | null>(null); + + useEffect(() => { + const el = ref.current; + if (el) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + } + }, [value]); + + const handle = (v: string) => { + onChange(v); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(onSave, 1500); + }; + + const count = words + ? `${value.trim().split(/\s+/).filter(Boolean).length} words (${words[0]}–${words[1]})` + : `${value.length}${max ? `/${max}` : ""}`; + const over = max !== undefined && value.length > max; + const warn = max !== undefined && !over && value.length >= max * 0.9; + + return ( +