From 155507f21a031d389b301178fae5189f334d04b7 Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:58:32 -0400 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20PodcastYes=20=E2=80=94=20AI?= =?UTF-8?q?=20podcast=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 51 + .gitignore | 41 + DESIGN.md | 223 + README.md | 88 + app/(admin)/admin/actions.ts | 68 + app/(admin)/admin/ai-usage/page.tsx | 94 + app/(admin)/admin/audit/page.tsx | 48 + app/(admin)/admin/flags/page.tsx | 16 + app/(admin)/admin/health/page.tsx | 93 + app/(admin)/admin/moderation/page.tsx | 45 + app/(admin)/admin/page.tsx | 96 + app/(admin)/admin/subscriptions/page.tsx | 91 + app/(admin)/admin/users/page.tsx | 34 + app/(admin)/layout.tsx | 45 + app/(app)/api-keys/actions.ts | 46 + app/(app)/api-keys/page.tsx | 46 + app/(app)/billing/actions.ts | 115 + app/(app)/billing/page.tsx | 56 + app/(app)/dashboard/page.tsx | 131 + app/(app)/episodes/[id]/page.tsx | 103 + app/(app)/episodes/[id]/repurpose/page.tsx | 50 + app/(app)/episodes/actions.ts | 329 + app/(app)/episodes/new/page.tsx | 21 + app/(app)/episodes/page.tsx | 69 + app/(app)/layout.tsx | 56 + app/(app)/series/[id]/page.tsx | 56 + app/(app)/series/actions.ts | 101 + app/(app)/series/page.tsx | 76 + app/(app)/settings/page.tsx | 16 + app/(app)/team/actions.ts | 51 + app/(app)/team/page.tsx | 68 + app/(app)/usage/page.tsx | 97 + app/(auth)/forgot-password/page.tsx | 8 + app/(auth)/layout.tsx | 20 + app/(auth)/reset-password/page.tsx | 13 + app/(auth)/sign-in/page.tsx | 18 + app/(auth)/sign-up/page.tsx | 13 + app/(marketing)/layout.tsx | 12 + app/(marketing)/page.tsx | 242 + app/(marketing)/pricing/page.tsx | 76 + app/(marketing)/privacy/page.tsx | 20 + app/(marketing)/terms/page.tsx | 20 + app/api/assets/[...key]/route.ts | 65 + app/api/auth/[...all]/route.ts | 4 + app/api/episodes/[id]/stream/route.ts | 87 + app/api/v1/episodes/route.ts | 103 + app/api/webhooks/paypal/route.ts | 33 + app/api/webhooks/stripe/route.ts | 29 + app/globals.css | 104 + app/layout.tsx | 46 + components.json | 21 + components/admin/admin-sidebar.tsx | 52 + components/admin/cost-chart.tsx | 38 + components/admin/flags-client.tsx | 73 + components/admin/moderation-actions.tsx | 29 + components/admin/users-table.tsx | 105 + components/app/api-keys-client.tsx | 112 + components/app/audio-player.tsx | 39 + components/app/billing-client.tsx | 213 + components/app/episode-actions.tsx | 66 + components/app/episode-card.tsx | 47 + components/app/episode-status-badge.tsx | 26 + components/app/episode-wizard.tsx | 336 + components/app/generation-progress.tsx | 131 + components/app/page-header.tsx | 19 + components/app/repurpose-client.tsx | 86 + components/app/script-editor.tsx | 153 + components/app/series-create-form.tsx | 127 + components/app/series-detail-client.tsx | 59 + components/app/settings-client.tsx | 97 + components/app/sidebar-nav.tsx | 72 + components/app/team-client.tsx | 222 + components/app/upgrade-gate.tsx | 31 + components/app/user-menu.tsx | 74 + components/auth/forgot-password-form.tsx | 78 + components/auth/google-button.tsx | 43 + components/auth/reset-password-form.tsx | 81 + components/auth/sign-in-form.tsx | 86 + components/auth/sign-up-form.tsx | 93 + components/marketing/site-footer.tsx | 71 + components/marketing/site-header.tsx | 33 + components/ui/avatar.tsx | 42 + components/ui/badge.tsx | 31 + components/ui/button.tsx | 54 + components/ui/card.tsx | 50 + components/ui/dropdown-menu.tsx | 83 + components/ui/input.tsx | 21 + components/ui/label.tsx | 22 + components/ui/progress.tsx | 24 + components/ui/select.tsx | 89 + components/ui/switch.tsx | 28 + components/ui/textarea.tsx | 20 + deploy/README.md | 107 + deploy/nginx-podcastyes.conf | 31 + ecosystem.config.js | 40 + lib/ai/cost.ts | 52 + lib/ai/ffmpeg.ts | 45 + lib/ai/openai.ts | 16 + lib/ai/pipeline/generate-episode.ts | 228 + lib/ai/pipeline/repurpose.ts | 53 + lib/ai/pipeline/segment.ts | 110 + lib/ai/pipeline/stitch.ts | 60 + lib/ai/prompts/script.ts | 93 + lib/ai/providers/elevenlabs-audio.ts | 97 + lib/ai/providers/index.ts | 21 + lib/ai/providers/openai-art.ts | 36 + lib/ai/providers/openai-script.ts | 90 + lib/ai/series.ts | 48 + lib/ai/types.ts | 103 + lib/ai/voices.ts | 36 + lib/apikeys.ts | 36 + lib/auth/auth-client.ts | 12 + lib/auth/auth.ts | 84 + lib/auth/guards.ts | 35 + lib/billing/catalog.ts | 50 + lib/billing/paypal.ts | 115 + lib/billing/plans.ts | 162 + lib/billing/stripe.ts | 76 + lib/billing/subscription.ts | 104 + lib/billing/webhooks/paypal.ts | 69 + lib/billing/webhooks/stripe.ts | 71 + lib/db.ts | 12 + lib/email/index.ts | 39 + lib/episodes/options.ts | 49 + lib/episodes/status.ts | 47 + lib/queue/jobs.ts | 38 + lib/queue/pgboss.ts | 60 + lib/ratelimit/index.ts | 42 + lib/storage/index.ts | 20 + lib/storage/local.ts | 69 + lib/storage/types.ts | 31 + lib/usage/limits.ts | 51 + lib/usage/meter.ts | 47 + lib/utils.ts | 24 + middleware.ts | 38 + next.config.mjs | 22 + package-lock.json | 9023 +++++++++++++++++ package.json | 75 + postcss.config.mjs | 9 + .../20260607075411_init/migration.sql | 545 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 492 + prisma/seed.ts | 38 + scripts/check-db.ts | 44 + scripts/make-admin.ts | 23 + scripts/postbuild.mjs | 22 + scripts/test-auth.ts | 44 + scripts/test-segment.ts | 32 + tailwind.config.ts | 109 + tsconfig.json | 23 + worker/index.ts | 65 + 151 files changed, 19826 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 README.md create mode 100644 app/(admin)/admin/actions.ts create mode 100644 app/(admin)/admin/ai-usage/page.tsx create mode 100644 app/(admin)/admin/audit/page.tsx create mode 100644 app/(admin)/admin/flags/page.tsx create mode 100644 app/(admin)/admin/health/page.tsx create mode 100644 app/(admin)/admin/moderation/page.tsx create mode 100644 app/(admin)/admin/page.tsx create mode 100644 app/(admin)/admin/subscriptions/page.tsx create mode 100644 app/(admin)/admin/users/page.tsx create mode 100644 app/(admin)/layout.tsx create mode 100644 app/(app)/api-keys/actions.ts create mode 100644 app/(app)/api-keys/page.tsx create mode 100644 app/(app)/billing/actions.ts create mode 100644 app/(app)/billing/page.tsx create mode 100644 app/(app)/dashboard/page.tsx create mode 100644 app/(app)/episodes/[id]/page.tsx create mode 100644 app/(app)/episodes/[id]/repurpose/page.tsx create mode 100644 app/(app)/episodes/actions.ts create mode 100644 app/(app)/episodes/new/page.tsx create mode 100644 app/(app)/episodes/page.tsx create mode 100644 app/(app)/layout.tsx create mode 100644 app/(app)/series/[id]/page.tsx create mode 100644 app/(app)/series/actions.ts create mode 100644 app/(app)/series/page.tsx create mode 100644 app/(app)/settings/page.tsx create mode 100644 app/(app)/team/actions.ts create mode 100644 app/(app)/team/page.tsx create mode 100644 app/(app)/usage/page.tsx create mode 100644 app/(auth)/forgot-password/page.tsx create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/reset-password/page.tsx create mode 100644 app/(auth)/sign-in/page.tsx create mode 100644 app/(auth)/sign-up/page.tsx create mode 100644 app/(marketing)/layout.tsx create mode 100644 app/(marketing)/page.tsx create mode 100644 app/(marketing)/pricing/page.tsx create mode 100644 app/(marketing)/privacy/page.tsx create mode 100644 app/(marketing)/terms/page.tsx create mode 100644 app/api/assets/[...key]/route.ts create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/episodes/[id]/stream/route.ts create mode 100644 app/api/v1/episodes/route.ts create mode 100644 app/api/webhooks/paypal/route.ts create mode 100644 app/api/webhooks/stripe/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 components.json create mode 100644 components/admin/admin-sidebar.tsx create mode 100644 components/admin/cost-chart.tsx create mode 100644 components/admin/flags-client.tsx create mode 100644 components/admin/moderation-actions.tsx create mode 100644 components/admin/users-table.tsx create mode 100644 components/app/api-keys-client.tsx create mode 100644 components/app/audio-player.tsx create mode 100644 components/app/billing-client.tsx create mode 100644 components/app/episode-actions.tsx create mode 100644 components/app/episode-card.tsx create mode 100644 components/app/episode-status-badge.tsx create mode 100644 components/app/episode-wizard.tsx create mode 100644 components/app/generation-progress.tsx create mode 100644 components/app/page-header.tsx create mode 100644 components/app/repurpose-client.tsx create mode 100644 components/app/script-editor.tsx create mode 100644 components/app/series-create-form.tsx create mode 100644 components/app/series-detail-client.tsx create mode 100644 components/app/settings-client.tsx create mode 100644 components/app/sidebar-nav.tsx create mode 100644 components/app/team-client.tsx create mode 100644 components/app/upgrade-gate.tsx create mode 100644 components/app/user-menu.tsx create mode 100644 components/auth/forgot-password-form.tsx create mode 100644 components/auth/google-button.tsx create mode 100644 components/auth/reset-password-form.tsx create mode 100644 components/auth/sign-in-form.tsx create mode 100644 components/auth/sign-up-form.tsx create mode 100644 components/marketing/site-footer.tsx create mode 100644 components/marketing/site-header.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 deploy/README.md create mode 100644 deploy/nginx-podcastyes.conf create mode 100644 ecosystem.config.js create mode 100644 lib/ai/cost.ts create mode 100644 lib/ai/ffmpeg.ts create mode 100644 lib/ai/openai.ts create mode 100644 lib/ai/pipeline/generate-episode.ts create mode 100644 lib/ai/pipeline/repurpose.ts create mode 100644 lib/ai/pipeline/segment.ts create mode 100644 lib/ai/pipeline/stitch.ts create mode 100644 lib/ai/prompts/script.ts create mode 100644 lib/ai/providers/elevenlabs-audio.ts create mode 100644 lib/ai/providers/index.ts create mode 100644 lib/ai/providers/openai-art.ts create mode 100644 lib/ai/providers/openai-script.ts create mode 100644 lib/ai/series.ts create mode 100644 lib/ai/types.ts create mode 100644 lib/ai/voices.ts create mode 100644 lib/apikeys.ts create mode 100644 lib/auth/auth-client.ts create mode 100644 lib/auth/auth.ts create mode 100644 lib/auth/guards.ts create mode 100644 lib/billing/catalog.ts create mode 100644 lib/billing/paypal.ts create mode 100644 lib/billing/plans.ts create mode 100644 lib/billing/stripe.ts create mode 100644 lib/billing/subscription.ts create mode 100644 lib/billing/webhooks/paypal.ts create mode 100644 lib/billing/webhooks/stripe.ts create mode 100644 lib/db.ts create mode 100644 lib/email/index.ts create mode 100644 lib/episodes/options.ts create mode 100644 lib/episodes/status.ts create mode 100644 lib/queue/jobs.ts create mode 100644 lib/queue/pgboss.ts create mode 100644 lib/ratelimit/index.ts create mode 100644 lib/storage/index.ts create mode 100644 lib/storage/local.ts create mode 100644 lib/storage/types.ts create mode 100644 lib/usage/limits.ts create mode 100644 lib/usage/meter.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/migrations/20260607075411_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 scripts/check-db.ts create mode 100644 scripts/make-admin.ts create mode 100644 scripts/postbuild.mjs create mode 100644 scripts/test-auth.ts create mode 100644 scripts/test-segment.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 worker/index.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a9f3e40 --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# ─────────────────────────── Core ─────────────────────────── +# External Postgres (also hosts the pg-boss queue schema). Used by Prisma + pg-boss. +DATABASE_URL="postgresql://user:password@host:5432/podcastyes?schema=public" + +# Better Auth — generate a strong secret: `openssl rand -base64 32` +BETTER_AUTH_SECRET="change-me" +BETTER_AUTH_URL="http://localhost:3000" +NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# ─────────────────────────── OAuth ────────────────────────── +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# ─────────────────────────── AI providers ─────────────────── +OPENAI_API_KEY="" +ELEVENLABS_API_KEY="" + +# ─────────────────────────── Local-disk storage ───────────── +# Absolute path on the VPS where generated MP3s/art are written. Excluded from deploys. +STORAGE_DIR="./storage" +# Public base path for cover art served by nginx (maps to STORAGE_DIR/art). +MEDIA_PUBLIC_BASE_URL="/media" + +# ─────────────────────────── Billing: Stripe ──────────────── +STRIPE_SECRET_KEY="" +STRIPE_WEBHOOK_SECRET="" +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="" +STRIPE_PRICE_CREATOR_MONTHLY="" +STRIPE_PRICE_CREATOR_YEARLY="" +STRIPE_PRICE_PRO_MONTHLY="" +STRIPE_PRICE_PRO_YEARLY="" +STRIPE_PRICE_AGENCY_MONTHLY="" +STRIPE_PRICE_AGENCY_YEARLY="" + +# ─────────────────────────── Billing: PayPal ──────────────── +PAYPAL_CLIENT_ID="" +PAYPAL_CLIENT_SECRET="" +PAYPAL_WEBHOOK_ID="" +# https://api-m.sandbox.paypal.com (sandbox) | https://api-m.paypal.com (live) +PAYPAL_API_BASE="https://api-m.sandbox.paypal.com" +PAYPAL_PLAN_CREATOR="" +PAYPAL_PLAN_PRO="" +PAYPAL_PLAN_AGENCY="" + +# ─────────────────────────── Email ────────────────────────── +RESEND_API_KEY="" +EMAIL_FROM="PodcastYes " + +# ─────────────────────────── Worker ───────────────────────── +# Max concurrent audio-generation jobs on the worker process (keep low on a shared VPS). +WORKER_CONCURRENCY="2" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b9aea0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# prisma +/prisma/migrations/dev.db* + +# generated assets (served from local disk in prod — never committed) +/storage/ + +# misc +.DS_Store +*.pem +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files +.env +.env*.local +.env.production + +# editor / os +.vscode/* +!.vscode/extensions.json +.idea/ + +# pm2 +.pm2/ + +# typescript +*.tsbuildinfo diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..d4083ad --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,223 @@ +# PodcastYes — Design System + +> A Wix.com‑inspired visual language. This document is the single source of truth for +> the look and feel of the entire platform — marketing, auth, app, and admin. Every +> screen must follow it. When a component or page disagrees with this doc, the doc wins. + +Reference: [Wix Design Assets & Guidelines](https://www.wix.com/about/design-assets), +[Wix Studio Brand Guidelines](https://www.wix.com/studio/about/brand-guidelines), +[About the Wix Design System](https://dev.wix.com/docs/build-apps/develop-your-app/design/about-the-wix-design-system). + +--- + +## 1. Design principles (the Wix feel) + +1. **Confident & editorial.** Big, bold, tightly‑tracked headlines. Generous whitespace. + Let one idea own each section. Marketing reads like a magazine, not a SaaS dashboard. +2. **High contrast, mostly monochrome.** Near‑black on white is the base. Color is used + deliberately — almost always the single **Wix Blue** accent — never decoratively. +3. **Black is the primary action.** Primary buttons are solid black pills. Blue is the + *interactive / accent* color (links, focus rings, active nav, highlights). This is the + defining Wix.com marketing combination. +4. **Soft, rounded, friendly.** Pill buttons, large card radii, gentle shadows. Nothing + sharp; nothing heavy. +5. **Calm surfaces.** White and one light‑gray (`#F4F4F4`). Borders are hairline and light. + Depth comes from spacing and soft shadow, not from boxes inside boxes. +6. **One typeface, many weights.** Wix Madefor everywhere. Hierarchy comes from size and + weight, not from mixing families. + +--- + +## 2. Color + +### Core palette + +| Token | Hex | HSL | Use | +| ---------------- | ---------- | ------------------ | --- | +| **Ink / black** | `#0B0B0B` | `0 0% 5%` | Primary buttons, headlines, default text | +| **White** | `#FFFFFF` | `0 0% 100%` | Page background, cards | +| **Wix Blue** | `#116DFF` | `217 100% 53%` | Links, focus rings, active states, highlights, brand mark | +| **Blue (hover)** | `#0B57E0` | `219 90% 46%` | Blue hover / pressed | +| **Surface** | `#F4F4F4` | `0 0% 96%` | Section backgrounds, muted fills, inputs‑on‑white | +| **Border** | `#E4E4E4` | `0 0% 89%` | Hairline dividers, card borders, input borders | +| **Muted text** | `#6A6A6A` | `0 0% 42%` | Secondary / supporting copy | +| **Foreground** | `#111111` | `0 0% 7%` | Body text | + +### Accent markers (use sparingly, for illustration/badges/status only) + +Wix pairs its monochrome base with a few bright markers. Use these only for data viz, +status, and small decorative moments — never for primary UI chrome. + +| Name | Hex | Use | +| ----------- | ---------- | --- | +| Lime | `#DEFF00` | Highlight chip, "new" marker | +| Orange | `#FF5500` | Warning / attention | +| Green | `#00C271` | Success | +| Deep Purple | `#3910ED` | Secondary data series | +| Light Blue | `#ADD7FF` | Tints, illustration fills | + +### Semantic mapping (CSS variables, HSL triplets) + +``` +--background 0 0% 100% /* white page */ +--foreground 0 0% 7% /* near-black text */ +--card 0 0% 100% +--card-foreground 0 0% 7% +--primary 0 0% 5% /* INK — black buttons */ +--primary-foreground 0 0% 100% +--brand 217 100% 53% /* WIX BLUE — links, accents, active */ +--brand-foreground 0 0% 100% +--secondary 0 0% 96% /* surface gray fill */ +--secondary-foreground 0 0% 7% +--muted 0 0% 96% +--muted-foreground 0 0% 42% +--accent 0 0% 96% /* hover fill */ +--accent-foreground 0 0% 7% +--destructive 4 86% 58% +--success 157 100% 38% +--warning 24 100% 50% +--border 0 0% 89% +--input 0 0% 89% +--ring 217 100% 53% /* focus ring = Wix Blue */ +``` + +**Rules** +- Default text = `foreground`. Secondary text = `muted-foreground`. Never lighter than `#6A6A6A` on white. +- Links and any "interactive accent" = **Wix Blue** (`text-brand`). Hover → underline or darker blue. +- Primary CTA = **black** pill. Never blue and black competing in the same button group. +- Focus is always a **2px Wix‑Blue ring** with a 2px offset. No exceptions. + +--- + +## 3. Typography + +**Family:** **Wix Madefor** (via Google Fonts / `next/font`). +- `Wix Madefor Display` → headings (`--font-display`). +- `Wix Madefor Text` → body & UI (`--font-sans`). + +System fallback stack: `ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif`. + +### Type scale + +| Role | Size (desktop) | Weight | Tracking | Notes | +| --------------- | --------------------- | ------ | -------- | ----- | +| Display / Hero | `clamp(2.75rem,6vw,4.5rem)` (44–72px) | 800 | `-0.03em` | Display font, `leading-[1.05]` | +| H1 (page) | `2.25–3rem` (36–48px) | 700 | `-0.02em` | Display font | +| H2 (section) | `1.75–2.25rem` (28–36px)| 700 | `-0.02em` | Display font | +| H3 | `1.25rem` (20px) | 600 | `-0.01em` | | +| Lead paragraph | `1.125–1.25rem` (18–20px)| 400 | normal | `text-muted-foreground` | +| Body | `1rem` (16px) | 400 | normal | base UI = 14–16px | +| Small / meta | `0.875rem` (14px) | 500 | normal | | +| Eyebrow / label | `0.8125rem` (13px) | 600 | `0.04em` UPPERCASE | Use **Wix Blue** or muted | + +**Rules** +- Headlines are tight: always pair big display sizes with `tracking-tight` and `leading-[1.05–1.1]`. +- Body line length caps at ~`max-w-2xl` (≈42rem) for readability. +- Don't uppercase anything except small eyebrows/labels. +- Numbers in stats/pricing use the display font, weight 800. + +--- + +## 4. Shape, elevation, spacing + +### Radius +- **Buttons:** pill — `rounded-full` (Wix CTA buttons ≈ 18px+; pill at our sizes). +- **Inputs / selects:** `rounded-xl` (12px). +- **Cards / panels:** `rounded-2xl` (16px) default; hero/feature cards `rounded-3xl` (24px). +- **Chips / badges:** `rounded-full`. +- Base `--radius: 0.875rem`. + +### Elevation (soft, never harsh) +``` +shadow-sm → 0 1px 2px rgba(11,11,11,.05) +shadow → 0 2px 8px rgba(11,11,11,.06) +shadow-md → 0 8px 24px rgba(11,11,11,.08) /* raised cards, menus */ +shadow-lg → 0 16px 48px rgba(11,11,11,.12) /* popovers, highlighted pricing */ +``` +Cards default to a hairline `border` + `shadow-sm`. Elevate on hover (`hover:shadow-md`) +for interactive cards. No hard drop shadows, no inner shadows. + +### Spacing & layout +- Container: centered, `max-w-[1280px]`, horizontal padding `1.5rem` (mobile) → `2rem`. +- **Section rhythm:** `py-20` mobile → `py-28`/`py-32` desktop. Marketing breathes. +- Alternate section backgrounds white ↔ `secondary` (`#F4F4F4`) to segment the page. +- Card padding: `p-6` (compact) to `p-8` (marketing). +- Grid gaps: `gap-6` default, `gap-8` for marketing feature grids. + +--- + +## 5. Components + +### Buttons (` + + + + +
+ +
+
{children}
+
+
+ + ); +} diff --git a/app/(app)/api-keys/actions.ts b/app/(app)/api-keys/actions.ts new file mode 100644 index 0000000..acf5c8b --- /dev/null +++ b/app/(app)/api-keys/actions.ts @@ -0,0 +1,46 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { subjectHasFeature } from "@/lib/billing/subscription"; +import { generateRawKey, hashKey, keyPreview } from "@/lib/apikeys"; + +export async function createApiKeyAction( + name: string +): Promise<{ ok: boolean; error?: string; key?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const allowed = await subjectHasFeature( + session.user.id, + "api_access", + session.session.activeOrganizationId + ); + if (!allowed) return { ok: false, error: "API access requires the Pro plan or higher." }; + + const trimmed = name.trim() || "Untitled key"; + const raw = generateRawKey(); + await prisma.apiKey.create({ + data: { + userId: session.user.id, + name: trimmed, + hashedKey: hashKey(raw), + prefix: keyPreview(raw), + }, + }); + + revalidatePath("/api-keys"); + // Return the raw key once — it is never stored in plaintext. + return { ok: true, key: raw }; +} + +export async function revokeApiKeyAction(id: string): Promise<{ ok: boolean; error?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + const key = await prisma.apiKey.findUnique({ where: { id }, select: { userId: true } }); + if (!key || key.userId !== session.user.id) return { ok: false, error: "Not allowed." }; + await prisma.apiKey.update({ where: { id }, data: { revokedAt: new Date() } }); + revalidatePath("/api-keys"); + return { ok: true }; +} diff --git a/app/(app)/api-keys/page.tsx b/app/(app)/api-keys/page.tsx new file mode 100644 index 0000000..2ab192b --- /dev/null +++ b/app/(app)/api-keys/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { requireAuth } from "@/lib/auth/guards"; +import { subjectHasFeature } from "@/lib/billing/subscription"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { UpgradeGate } from "@/components/app/upgrade-gate"; +import { ApiKeysClient } from "@/components/app/api-keys-client"; + +export const metadata: Metadata = { title: "API keys" }; + +export default async function ApiKeysPage() { + const session = await requireAuth(); + const allowed = await subjectHasFeature( + session.user.id, + "api_access", + session.session.activeOrganizationId + ); + + return ( + <> + + {!allowed ? ( + + ) : ( + ({ + id: k.id, + name: k.name, + prefix: k.prefix, + lastUsedAt: k.lastUsedAt?.toISOString() ?? null, + createdAt: k.createdAt.toISOString(), + }))} + /> + )} + + ); +} diff --git a/app/(app)/billing/actions.ts b/app/(app)/billing/actions.ts new file mode 100644 index 0000000..540f37d --- /dev/null +++ b/app/(app)/billing/actions.ts @@ -0,0 +1,115 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { + stripe, + createStripeCheckout, + createStripePortal, + isStripeConfigured, +} from "@/lib/billing/stripe"; +import { + createPaypalSubscription, + cancelPaypalSubscription, + isPaypalConfigured, +} from "@/lib/billing/paypal"; +import { paypalPlanId, type BillingInterval } from "@/lib/billing/catalog"; +import { getActiveSubscription } from "@/lib/billing/subscription"; +import type { PlanKey } from "@/lib/billing/plans"; + +type ActionResult = { ok: true; url?: string } | { ok: false; error: string }; + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : "Something went wrong"; +} + +export async function startStripeCheckoutAction( + plan: PlanKey, + interval: BillingInterval +): Promise { + const session = await getServerSession(); + if (!session) return { ok: false, error: "Please sign in." }; + if (!isStripeConfigured()) return { ok: false, error: "Card payments aren't configured yet." }; + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { id: true, email: true, name: true, stripeCustomerId: true }, + }); + if (!user) return { ok: false, error: "Account not found." }; + + try { + const url = await createStripeCheckout({ + user, + plan, + interval, + subjectId: user.id, + subjectType: "user", + }); + return { ok: true, url }; + } catch (e) { + return { ok: false, error: errMsg(e) }; + } +} + +export async function startPaypalCheckoutAction(plan: PlanKey): Promise { + const session = await getServerSession(); + if (!session) return { ok: false, error: "Please sign in." }; + if (!isPaypalConfigured()) return { ok: false, error: "PayPal isn't configured yet." }; + + const planId = paypalPlanId(plan); + if (!planId) return { ok: false, error: "PayPal plan isn't configured for this tier." }; + + try { + const { approveUrl } = await createPaypalSubscription({ + planId, + custom: { subjectId: session.user.id, subjectType: "user", plan }, + }); + return { ok: true, url: approveUrl }; + } catch (e) { + return { ok: false, error: errMsg(e) }; + } +} + +export async function openStripePortalAction(): Promise { + const session = await getServerSession(); + if (!session) return { ok: false, error: "Please sign in." }; + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { stripeCustomerId: true }, + }); + if (!user?.stripeCustomerId) return { ok: false, error: "No billing account yet." }; + try { + const url = await createStripePortal(user.stripeCustomerId); + return { ok: true, url }; + } catch (e) { + return { ok: false, error: errMsg(e) }; + } +} + +export async function cancelSubscriptionAction(): Promise { + const session = await getServerSession(); + if (!session) return { ok: false, error: "Please sign in." }; + const sub = await getActiveSubscription(session.user.id); + if (!sub) return { ok: false, error: "No active subscription." }; + + try { + if (sub.provider === "paypal" && sub.paypalSubscriptionId) { + await cancelPaypalSubscription(sub.paypalSubscriptionId); + await prisma.subscription.update({ + where: { id: sub.id }, + data: { status: "canceled", cancelAtPeriodEnd: true }, + }); + } else if (sub.provider === "stripe" && sub.stripeSubscriptionId) { + await stripe().subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: true }); + await prisma.subscription.update({ + where: { id: sub.id }, + data: { cancelAtPeriodEnd: true }, + }); + } + revalidatePath("/billing"); + return { ok: true }; + } catch (e) { + return { ok: false, error: errMsg(e) }; + } +} diff --git a/app/(app)/billing/page.tsx b/app/(app)/billing/page.tsx new file mode 100644 index 0000000..ccc5668 --- /dev/null +++ b/app/(app)/billing/page.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from "next"; +import { requireAuth } from "@/lib/auth/guards"; +import { getEffectivePlan, getActiveSubscription } from "@/lib/billing/subscription"; +import { isStripeConfigured } from "@/lib/billing/stripe"; +import { isPaypalConfigured } from "@/lib/billing/paypal"; +import { PageHeader } from "@/components/app/page-header"; +import { BillingClient } from "@/components/app/billing-client"; + +export const metadata: Metadata = { title: "Billing" }; + +export default async function BillingPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string }>; +}) { + const session = await requireAuth(); + const { status } = await searchParams; + const { key: currentPlan } = await getEffectivePlan( + session.user.id, + session.session.activeOrganizationId + ); + const sub = await getActiveSubscription(session.user.id); + + return ( + <> + + + {status === "success" && ( +
+ Payment received — your plan will update momentarily once the provider confirms. +
+ )} + {status === "cancel" && ( +
+ Checkout canceled. No changes were made. +
+ )} + + + + ); +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..0266637 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -0,0 +1,131 @@ +import Link from "next/link"; +import { Mic2, Plus, Sparkles, ArrowRight } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { getEffectivePlan } from "@/lib/billing/subscription"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { EpisodeStatusBadge } from "@/components/app/episode-status-badge"; + +export default async function DashboardPage() { + const session = await requireAuth(); + const { plan, key } = await getEffectivePlan( + session.user.id, + session.session.activeOrganizationId + ); + + const [episodeCount, recent] = await Promise.all([ + prisma.episode.count({ where: { userId: session.user.id } }), + prisma.episode.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + take: 5, + select: { id: true, title: true, status: true, format: true, createdAt: true }, + }), + ]); + + const firstName = session.user.name.split(" ")[0]; + + return ( + <> + + + New episode + + + } + /> + +
+ + + Episodes created + + +

{episodeCount}

+
+
+ + + Current plan + + +

{plan.name}

+ {key === "free" && ( + + )} +
+
+ + + Usage this month + + + + + +
+ + + + Recent episodes + + + + {recent.length === 0 ? ( +
+ + + +
+

No episodes yet

+

+ Create your first AI-produced episode to get started. +

+
+ +
+ ) : ( +
    + {recent.map((ep) => ( +
  • + +
    +

    {ep.title}

    +

    + {ep.format.replace("_", "-").toLowerCase()} ·{" "} + {ep.createdAt.toLocaleDateString()} +

    +
    + + +
  • + ))} +
+ )} +
+
+ + ); +} diff --git a/app/(app)/episodes/[id]/page.tsx b/app/(app)/episodes/[id]/page.tsx new file mode 100644 index 0000000..9294d6a --- /dev/null +++ b/app/(app)/episodes/[id]/page.tsx @@ -0,0 +1,103 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { Mic2, Repeat } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { GenerationProgress } from "@/components/app/generation-progress"; +import { ScriptEditor } from "@/components/app/script-editor"; +import { AudioPlayer } from "@/components/app/audio-player"; +import { EpisodeActions } from "@/components/app/episode-actions"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import type { StructuredScript } from "@/lib/ai/types"; + +export default async function EpisodePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const session = await requireAuth(); + + const episode = await prisma.episode.findUnique({ + where: { id }, + include: { script: true, audioAsset: true, coverArt: true, speakers: true }, + }); + if (!episode) notFound(); + if (episode.userId !== session.user.id && session.user.role !== "admin") notFound(); + + const inProgress = !["READY", "FAILED"].includes(episode.status); + const speakerNames: Record = {}; + for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName; + + return ( + <> + : undefined} + /> + + {episode.status === "FAILED" || inProgress ? ( + + ) : ( +
+
+ +
+ {episode.coverArt ? ( + // eslint-disable-next-line @next/next/no-img-element + {episode.title} + ) : ( +
+ +
+ )} +
+
+ + {episode.audioAsset && ( + + + + + + )} + + +
+ +
+ {episode.script ? ( + + ) : ( + + + No script available. + + + )} +
+
+ )} + + ); +} diff --git a/app/(app)/episodes/[id]/repurpose/page.tsx b/app/(app)/episodes/[id]/repurpose/page.tsx new file mode 100644 index 0000000..6f8859c --- /dev/null +++ b/app/(app)/episodes/[id]/repurpose/page.tsx @@ -0,0 +1,50 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { RepurposeClient } from "@/components/app/repurpose-client"; +import { Button } from "@/components/ui/button"; + +type Format = "blog" | "social_thread" | "newsletter"; +type Content = { title: string; body: string } | null; + +export default async function RepurposePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const session = await requireAuth(); + + const episode = await prisma.episode.findUnique({ + where: { id }, + select: { + userId: true, + title: true, + script: { select: { id: true } }, + repurposed: { orderBy: { createdAt: "desc" } }, + }, + }); + if (!episode) notFound(); + if (episode.userId !== session.user.id && session.user.role !== "admin") notFound(); + + // Latest content per format. + const initial: Record = { blog: null, social_thread: null, newsletter: null }; + for (const r of episode.repurposed) { + const key = r.type as Format; + if (key in initial && !initial[key]) initial[key] = r.content as unknown as Content; + } + + return ( + <> + + + + + ); +} diff --git a/app/(app)/episodes/actions.ts b/app/(app)/episodes/actions.ts new file mode 100644 index 0000000..276ec31 --- /dev/null +++ b/app/(app)/episodes/actions.ts @@ -0,0 +1,329 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { getEffectivePlan } from "@/lib/billing/subscription"; +import { enforceLimit, LimitExceededError } from "@/lib/usage/limits"; +import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; +import type { GenerationType } from "@/lib/queue/jobs"; +import type { Prisma } from "@prisma/client"; + +const speakerSchema = z.object({ + speakerKey: z.string().min(1).max(40), + displayName: z.string().min(1).max(60), + elevenVoiceId: z.string().min(1).max(60), +}); + +const createSchema = z.object({ + title: z.string().max(120).optional(), + topic: z.string().min(10, "Describe your topic in a bit more detail").max(2000), + tone: z.string().min(1), + format: z.enum(["SOLO", "INTERVIEW", "MULTI_HOST"]), + language: z.string().min(2).max(5), + targetLengthMin: z.number().int().min(1).max(180), + audience: z.string().max(200).optional(), + speakers: z.array(speakerSchema).min(1).max(6), +}); + +export type CreateEpisodeInput = z.infer; +export type CreateEpisodeResult = + | { ok: true; episodeId: string } + | { ok: false; error: string; limited?: boolean }; + +export async function createEpisodeAction(input: CreateEpisodeInput): Promise { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const rl = await rateLimit("generation", session.user.id, LIMITS.generation); + if (!rl.ok) { + return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` }; + } + + const parsed = createSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + } + const data = parsed.data; + const activeOrgId = session.session.activeOrganizationId; + + const { plan } = await getEffectivePlan(session.user.id, activeOrgId); + if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) { + return { + ok: false, + error: `The ${plan.name} plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes.`, + limited: true, + }; + } + + try { + await enforceLimit(session.user.id, "script", activeOrgId); + await enforceLimit(session.user.id, "audio", activeOrgId); + } catch (err) { + if (err instanceof LimitExceededError) { + return { + ok: false, + error: `You've reached your monthly ${err.check.metric} limit on the ${err.check.plan} plan. Upgrade to keep creating.`, + limited: true, + }; + } + throw err; + } + + const episode = await prisma.episode.create({ + data: { + userId: session.user.id, + organizationId: activeOrgId ?? undefined, + title: data.title?.trim() || deriveTitle(data.topic), + topic: data.topic, + tone: data.tone, + format: data.format, + language: data.language, + targetLengthMin: data.targetLengthMin, + audience: data.audience, + status: "QUEUED", + stage: "Queued for generation", + speakers: { + create: data.speakers.map((s) => ({ + speakerKey: s.speakerKey, + displayName: s.displayName, + elevenVoiceId: s.elevenVoiceId, + })), + }, + jobs: { create: { type: "full", status: "queued" } }, + }, + }); + + await enqueueEpisodeGeneration( + { episodeId: episode.id, type: "full" }, + { priority: plan.features.includes("priority_generation") ? 10 : 0 } + ); + + revalidatePath("/episodes"); + revalidatePath("/dashboard"); + return { ok: true, episodeId: episode.id }; +} + +export async function regenerateAction( + episodeId: string, + type: GenerationType +): Promise<{ ok: boolean; error?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const episode = await prisma.episode.findUnique({ + where: { id: episodeId }, + select: { userId: true, organizationId: true }, + }); + if (!episode) return { ok: false, error: "Episode not found." }; + if (episode.userId !== session.user.id && session.user.role !== "admin") { + return { ok: false, error: "Not allowed." }; + } + + // Gate the metrics this regeneration will consume. + const metrics: ("script" | "audio" | "art")[] = + type === "art" ? ["art"] : type === "audio" ? ["audio"] : ["script", "audio"]; + try { + for (const m of metrics) await enforceLimit(session.user.id, m, session.session.activeOrganizationId); + } catch (err) { + if (err instanceof LimitExceededError) { + return { ok: false, error: `Monthly ${err.check.metric} limit reached on the ${err.check.plan} plan.` }; + } + throw err; + } + + await prisma.episode.update({ + where: { id: episodeId }, + data: { status: "QUEUED", stage: "Queued for regeneration", errorMessage: null }, + }); + await prisma.generationJob.create({ data: { episodeId, type, status: "queued" } }); + await enqueueEpisodeGeneration({ episodeId, type }); + + revalidatePath(`/episodes/${episodeId}`); + return { ok: true }; +} + +const scriptContentSchema = z.object({ + title: z.string().min(1), + sections: z + .array( + z.object({ + id: z.string().min(1), + title: z.string().min(1), + turns: z.array(z.object({ speakerKey: z.string(), text: z.string() })).min(1), + }) + ) + .min(1), +}); + +export async function updateScriptAction( + episodeId: string, + content: unknown +): Promise<{ ok: boolean; error?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const episode = await prisma.episode.findUnique({ + where: { id: episodeId }, + select: { userId: true }, + }); + if (!episode || episode.userId !== session.user.id) return { ok: false, error: "Not allowed." }; + + const parsed = scriptContentSchema.safeParse(content); + if (!parsed.success) return { ok: false, error: "Invalid script format." }; + + await prisma.script.update({ + where: { episodeId }, + data: { content: parsed.data as unknown as Prisma.InputJsonValue, version: { increment: 1 } }, + }); + revalidatePath(`/episodes/${episodeId}`); + return { ok: true }; +} + +export async function regenerateSectionAction( + episodeId: string, + sectionId: string +): Promise<{ ok: boolean; error?: string; section?: { id: string; title: string; turns: { speakerKey: string; text: string }[] } }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const episode = await prisma.episode.findUnique({ + where: { id: episodeId }, + include: { speakers: true, script: true }, + }); + if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { + return { ok: false, error: "Not allowed." }; + } + if (!episode.script) return { ok: false, error: "No script to edit yet." }; + + try { + await enforceLimit(session.user.id, "script", session.session.activeOrganizationId); + } catch (err) { + if (err instanceof LimitExceededError) { + return { ok: false, error: `Monthly script limit reached on the ${err.check.plan} plan.` }; + } + throw err; + } + + // Imported lazily so the AI SDK never reaches client bundles importing this file. + const { scriptProvider } = await import("@/lib/ai/providers"); + const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost"); + const { incrementUsage } = await import("@/lib/usage/meter"); + + const config = { + title: episode.title, + topic: episode.topic, + tone: episode.tone, + format: episode.format, + language: episode.language, + targetLengthMin: episode.targetLengthMin, + audience: episode.audience ?? undefined, + speakers: episode.speakers.map((s) => ({ speakerKey: s.speakerKey, displayName: s.displayName })), + }; + const current = episode.script.content as unknown as { + title: string; + sections: { id: string; title: string; turns: { speakerKey: string; text: string }[] }[]; + }; + + const { section, usage } = await scriptProvider().regenerateSection(config, current, sectionId); + const updated = { + ...current, + sections: current.sections.map((s) => (s.id === sectionId ? section : s)), + }; + + await prisma.script.update({ + where: { episodeId }, + data: { content: updated as unknown as Prisma.InputJsonValue, version: { increment: 1 } }, + }); + + const ownerId = episode.organizationId ?? episode.userId; + const ownerType = episode.organizationId ? "organization" : "user"; + await incrementUsage(ownerId, ownerType, "script"); + await recordCost({ + provider: "openai", + operation: "script", + units: usage.inputTokens + usage.outputTokens, + costUsd: scriptCostUsd(usage), + episodeId, + userId: episode.userId, + }); + + revalidatePath(`/episodes/${episodeId}`); + return { ok: true, section }; +} + +export async function repurposeAction( + episodeId: string, + format: "blog" | "social_thread" | "newsletter" +): Promise<{ ok: boolean; error?: string; content?: { title: string; body: string } }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const episode = await prisma.episode.findUnique({ + where: { id: episodeId }, + include: { script: true }, + }); + if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { + return { ok: false, error: "Not allowed." }; + } + if (!episode.script) return { ok: false, error: "Generate the episode first." }; + + try { + await enforceLimit(session.user.id, "repurpose", session.session.activeOrganizationId); + } catch (err) { + if (err instanceof LimitExceededError) { + return { ok: false, error: `Monthly repurpose limit reached on the ${err.check.plan} plan.` }; + } + throw err; + } + + const { repurposeScript } = await import("@/lib/ai/pipeline/repurpose"); + const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost"); + const { incrementUsage } = await import("@/lib/usage/meter"); + + const { content, usage } = await repurposeScript( + episode.script.content as unknown as Parameters[0], + format + ); + + await prisma.repurposedContent.create({ + data: { episodeId, type: format, content: content as unknown as Prisma.InputJsonValue }, + }); + + const ownerId = episode.organizationId ?? episode.userId; + const ownerType = episode.organizationId ? "organization" : "user"; + await incrementUsage(ownerId, ownerType, "repurpose"); + await recordCost({ + provider: "openai", + operation: "repurpose", + units: usage.inputTokens + usage.outputTokens, + costUsd: scriptCostUsd(usage), + episodeId, + userId: episode.userId, + }); + + revalidatePath(`/episodes/${episodeId}/repurpose`); + return { ok: true, content }; +} + +export async function deleteEpisodeAction(episodeId: string): Promise<{ ok: boolean; error?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + const episode = await prisma.episode.findUnique({ + where: { id: episodeId }, + select: { userId: true }, + }); + if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { + return { ok: false, error: "Not allowed." }; + } + await prisma.episode.delete({ where: { id: episodeId } }); + revalidatePath("/episodes"); + return { ok: true }; +} + +function deriveTitle(topic: string): string { + const trimmed = topic.trim().replace(/\s+/g, " "); + return trimmed.length <= 60 ? trimmed : trimmed.slice(0, 57) + "…"; +} diff --git a/app/(app)/episodes/new/page.tsx b/app/(app)/episodes/new/page.tsx new file mode 100644 index 0000000..7956d28 --- /dev/null +++ b/app/(app)/episodes/new/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { requireAuth } from "@/lib/auth/guards"; +import { getEffectivePlan } from "@/lib/billing/subscription"; +import { PageHeader } from "@/components/app/page-header"; +import { EpisodeWizard } from "@/components/app/episode-wizard"; + +export const metadata: Metadata = { title: "Create an episode" }; + +export default async function NewEpisodePage() { + const session = await requireAuth(); + const { plan } = await getEffectivePlan(session.user.id, session.session.activeOrganizationId); + return ( + <> + + + + ); +} diff --git a/app/(app)/episodes/page.tsx b/app/(app)/episodes/page.tsx new file mode 100644 index 0000000..0ed394f --- /dev/null +++ b/app/(app)/episodes/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Mic2, Plus } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { EpisodeCard } from "@/components/app/episode-card"; +import { Button } from "@/components/ui/button"; + +export const metadata: Metadata = { title: "Episodes" }; + +export default async function EpisodesPage() { + const session = await requireAuth(); + const episodes = await prisma.episode.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + include: { coverArt: { select: { storageKey: true } } }, + }); + + return ( + <> + + + New episode + + + } + /> + + {episodes.length === 0 ? ( +
+ + + +
+

No episodes yet

+

Create your first AI-produced episode.

+
+ +
+ ) : ( +
+ {episodes.map((ep) => ( + + ))} +
+ )} + + ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..451744f --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; +import { Mic, Plus } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { getEffectivePlan } from "@/lib/billing/subscription"; +import { SidebarNav } from "@/components/app/sidebar-nav"; +import { UserMenu } from "@/components/app/user-menu"; +import { Button } from "@/components/ui/button"; + +// Authed, DB-backed dashboard — never statically prerender. +export const dynamic = "force-dynamic"; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const session = await requireAuth(); + const { key: plan } = await getEffectivePlan( + session.user.id, + session.session.activeOrganizationId + ); + const isAdmin = session.user.role === "admin"; + + return ( +
+
+ + + + + PodcastYes + +
+ + +
+
+ +
+ +
+
{children}
+
+
+
+ ); +} diff --git a/app/(app)/series/[id]/page.tsx b/app/(app)/series/[id]/page.tsx new file mode 100644 index 0000000..16e29ba --- /dev/null +++ b/app/(app)/series/[id]/page.tsx @@ -0,0 +1,56 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { SeriesDetailClient } from "@/components/app/series-detail-client"; +import { EpisodeStatusBadge } from "@/components/app/episode-status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +export default async function SeriesDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const session = await requireAuth(); + + const series = await prisma.series.findUnique({ + where: { id }, + include: { episodes: { orderBy: { createdAt: "desc" } } }, + }); + if (!series) notFound(); + if (series.userId !== session.user.id && session.user.role !== "admin") notFound(); + + const planned = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? []; + + return ( + <> + + + +

Planned episodes

+ + + {series.episodes.length > 0 && ( +
+

Generated

+
+ {series.episodes.map((ep) => ( + + + + {ep.title} + + + + + ))} +
+
+ )} + + ); +} diff --git a/app/(app)/series/actions.ts b/app/(app)/series/actions.ts new file mode 100644 index 0000000..0c87010 --- /dev/null +++ b/app/(app)/series/actions.ts @@ -0,0 +1,101 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import type { Prisma } from "@prisma/client"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { subjectHasFeature } from "@/lib/billing/subscription"; +import { enforceLimit, LimitExceededError } from "@/lib/usage/limits"; +import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; +import { FORMAT_SPEAKERS } from "@/lib/episodes/options"; +import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices"; + +const createSchema = z.object({ + theme: z.string().min(5).max(500), + count: z.number().int().min(2).max(12), + tone: z.string().min(1), + audience: z.string().max(200).optional(), + language: z.string().min(2).max(5), +}); + +export async function createSeriesAction( + input: z.infer +): Promise<{ ok: boolean; error?: string; seriesId?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + if (!(await subjectHasFeature(session.user.id, "series_generator", session.session.activeOrganizationId))) { + return { ok: false, error: "The series generator requires the Pro plan." }; + } + const parsed = createSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + + const { planSeason } = await import("@/lib/ai/series"); + const { plan } = await planSeason(parsed.data); + + const series = await prisma.series.create({ + data: { + userId: session.user.id, + organizationId: session.session.activeOrganizationId ?? undefined, + title: plan.title, + description: plan.description, + plannedCount: plan.episodes.length, + plan: plan.episodes as unknown as Prisma.InputJsonValue, + }, + }); + revalidatePath("/series"); + return { ok: true, seriesId: series.id }; +} + +export async function generateFromSeriesAction( + seriesId: string, + index: number +): Promise<{ ok: boolean; error?: string; episodeId?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const series = await prisma.series.findUnique({ where: { id: seriesId } }); + if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." }; + + const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? []; + const item = episodes[index]; + if (!item) return { ok: false, error: "Episode not found in plan." }; + + try { + await enforceLimit(session.user.id, "script", session.session.activeOrganizationId); + await enforceLimit(session.user.id, "audio", session.session.activeOrganizationId); + } catch (err) { + if (err instanceof LimitExceededError) { + return { ok: false, error: `Monthly ${err.check.metric} limit reached.` }; + } + throw err; + } + + const speakers = FORMAT_SPEAKERS.SOLO.map((s, i) => ({ + speakerKey: s.speakerKey, + displayName: s.defaultName, + elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id, + })); + + const episode = await prisma.episode.create({ + data: { + userId: session.user.id, + organizationId: series.organizationId ?? undefined, + seriesId: series.id, + title: item.title, + topic: item.topic, + tone: "Conversational", + format: "SOLO", + language: "en", + targetLengthMin: 10, + status: "QUEUED", + stage: "Queued for generation", + speakers: { create: speakers }, + jobs: { create: { type: "full", status: "queued" } }, + }, + }); + await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" }); + + revalidatePath(`/series/${seriesId}`); + return { ok: true, episodeId: episode.id }; +} diff --git a/app/(app)/series/page.tsx b/app/(app)/series/page.tsx new file mode 100644 index 0000000..61d313e --- /dev/null +++ b/app/(app)/series/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ListMusic } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { subjectHasFeature } from "@/lib/billing/subscription"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { UpgradeGate } from "@/components/app/upgrade-gate"; +import { SeriesCreateForm } from "@/components/app/series-create-form"; +import { Card, CardContent } from "@/components/ui/card"; + +export const metadata: Metadata = { title: "Series" }; + +export default async function SeriesPage() { + const session = await requireAuth(); + const allowed = await subjectHasFeature( + session.user.id, + "series_generator", + session.session.activeOrganizationId + ); + + if (!allowed) { + return ( + <> + + + + ); + } + + const series = await prisma.series.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + include: { _count: { select: { episodes: true } } }, + }); + + return ( + <> + + + + + {series.length > 0 && ( +
+

Your seasons

+
+ {series.map((s) => ( + + + + + + +
+

{s.title}

+

+ {s.plannedCount} planned · {s._count.episodes} generated +

+
+
+
+ + ))} +
+
+ )} + + ); +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx new file mode 100644 index 0000000..57c37ca --- /dev/null +++ b/app/(app)/settings/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { requireAuth } from "@/lib/auth/guards"; +import { PageHeader } from "@/components/app/page-header"; +import { SettingsClient } from "@/components/app/settings-client"; + +export const metadata: Metadata = { title: "Settings" }; + +export default async function SettingsPage() { + const session = await requireAuth(); + return ( + <> + + + + ); +} diff --git a/app/(app)/team/actions.ts b/app/(app)/team/actions.ts new file mode 100644 index 0000000..7403144 --- /dev/null +++ b/app/(app)/team/actions.ts @@ -0,0 +1,51 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; + +const brandingSchema = z.object({ + brandName: z.string().max(60).optional(), + primaryColor: z + .string() + .regex(/^#([0-9a-fA-F]{6})$/, "Use a hex colour like #7c3aed") + .optional() + .or(z.literal("")), + logoUrl: z.string().url().optional().or(z.literal("")), + removePoweredBy: z.boolean().optional(), +}); + +export async function saveBrandingAction( + organizationId: string, + data: z.infer +): Promise<{ ok: boolean; error?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const member = await prisma.member.findFirst({ + where: { organizationId, userId: session.user.id }, + select: { role: true }, + }); + if (!member || !["owner", "admin"].includes(member.role)) { + return { ok: false, error: "Only workspace owners can edit branding." }; + } + + const parsed = brandingSchema.safeParse(data); + if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + + const payload = { + brandName: parsed.data.brandName || null, + primaryColor: parsed.data.primaryColor || null, + logoUrl: parsed.data.logoUrl || null, + removePoweredBy: parsed.data.removePoweredBy ?? false, + }; + + await prisma.orgBranding.upsert({ + where: { organizationId }, + create: { organizationId, ...payload }, + update: payload, + }); + revalidatePath("/team"); + return { ok: true }; +} diff --git a/app/(app)/team/page.tsx b/app/(app)/team/page.tsx new file mode 100644 index 0000000..ed0ba17 --- /dev/null +++ b/app/(app)/team/page.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; +import { requireAuth } from "@/lib/auth/guards"; +import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription"; +import { prisma } from "@/lib/db"; +import { PageHeader } from "@/components/app/page-header"; +import { UpgradeGate } from "@/components/app/upgrade-gate"; +import { TeamClient } from "@/components/app/team-client"; + +export const metadata: Metadata = { title: "Team" }; + +export default async function TeamPage() { + const session = await requireAuth(); + const allowed = await subjectHasFeature( + session.user.id, + "team_workspace", + session.session.activeOrganizationId + ); + + if (!allowed) { + return ( + <> + + + + ); + } + + const { plan } = await getEffectivePlan(session.user.id, session.session.activeOrganizationId); + const membership = await prisma.member.findFirst({ + where: { userId: session.user.id }, + include: { + organization: { + include: { + branding: true, + members: { include: { user: { select: { name: true, email: true } } } }, + }, + }, + }, + }); + const org = membership?.organization ?? null; + const members = + org?.members.map((m) => ({ id: m.id, name: m.user.name, email: m.user.email, role: m.role })) ?? []; + + return ( + <> + + + + ); +} diff --git a/app/(app)/usage/page.tsx b/app/(app)/usage/page.tsx new file mode 100644 index 0000000..5a1810d --- /dev/null +++ b/app/(app)/usage/page.tsx @@ -0,0 +1,97 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { FileText, AudioLines, ImageIcon, Repeat, Infinity as InfinityIcon } from "lucide-react"; +import { requireAuth } from "@/lib/auth/guards"; +import { getEffectivePlan } from "@/lib/billing/subscription"; +import { getUsageSummary } from "@/lib/usage/meter"; +import { PageHeader } from "@/components/app/page-header"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans"; + +export const metadata: Metadata = { title: "Usage" }; + +const METRICS: { key: UsageMetric; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { key: "script", label: "Scripts", icon: FileText }, + { key: "audio", label: "Audio generations", icon: AudioLines }, + { key: "art", label: "Cover art", icon: ImageIcon }, + { key: "repurpose", label: "Repurposed content", icon: Repeat }, +]; + +function nextResetLabel(): string { + const now = new Date(); + const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + return next.toLocaleDateString(undefined, { month: "long", day: "numeric" }); +} + +export default async function UsagePage() { + const session = await requireAuth(); + const { plan, key, subjectId } = await getEffectivePlan( + session.user.id, + session.session.activeOrganizationId + ); + const usage = await getUsageSummary(subjectId); + + return ( + <> + + Upgrade plan + + ) : undefined + } + /> + +
+ Current plan + {plan.name} +
+ +
+ {METRICS.map((m) => { + const used = usage[m.key]; + const limit = plan.limits[m.key]; + const unlimited = limit === UNLIMITED; + const pct = unlimited ? 0 : Math.min(100, Math.round((used / Math.max(1, limit)) * 100)); + const atLimit = !unlimited && used >= limit; + return ( + + + + {m.label} + + {atLimit && Limit reached} + + +
+ {used} + + {unlimited ? ( + <> + Unlimited + + ) : ( + <>of {limit} + )} + +
+ {!unlimited && ( + + )} +
+
+ ); + })} +
+ + ); +} diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..6da947a --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { ForgotPasswordForm } from "@/components/auth/forgot-password-form"; + +export const metadata: Metadata = { title: "Forgot password" }; + +export default function ForgotPasswordPage() { + return ; +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..30595ff --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import { Mic } from "lucide-react"; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ + + + + PodcastYes + +
{children}
+
+ ); +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..d85201b --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { ResetPasswordForm } from "@/components/auth/reset-password-form"; + +export const metadata: Metadata = { title: "Reset password" }; + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..bd83c9f --- /dev/null +++ b/app/(auth)/sign-in/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@/lib/auth/guards"; +import { SignInForm } from "@/components/auth/sign-in-form"; + +export const metadata: Metadata = { title: "Sign in" }; + +export default async function SignInPage() { + const session = await getServerSession(); + if (session) redirect("/dashboard"); + const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + return ( + + + + ); +} diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..1b10cb2 --- /dev/null +++ b/app/(auth)/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@/lib/auth/guards"; +import { SignUpForm } from "@/components/auth/sign-up-form"; + +export const metadata: Metadata = { title: "Create account" }; + +export default async function SignUpPage() { + const session = await getServerSession(); + if (session) redirect("/dashboard"); + const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + return ; +} diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..138d594 --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import { SiteHeader } from "@/components/marketing/site-header"; +import { SiteFooter } from "@/components/marketing/site-footer"; + +export default function MarketingLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..f9cf55c --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,242 @@ +import Link from "next/link"; +import { + ArrowRight, + FileText, + AudioLines, + ImageIcon, + Sparkles, + Languages, + Users, + Repeat, + ListChecks, + Check, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { PLAN_ORDER, PLANS } from "@/lib/billing/plans"; +import { formatPrice } from "@/lib/utils"; + +export default function LandingPage() { + return ( + <> + {/* Hero */} +
+
+ + + GPT-4 · ElevenLabs · DALL·E — in one workflow + +

+ From a topic idea to a{" "} + finished podcast in minutes +

+

+ PodcastYes writes the script, records realistic multi-voice audio, and designs the cover + art — automatically. No microphone, no editing, no design skills required. +

+
+ + +
+

+ Free plan includes 3 scripts & 1 audio generation / month. No card required. +

+
+
+ + {/* How it works */} +
+
+ +
+ {STEPS.map((step, i) => ( + + +
+ + + + + {String(i + 1).padStart(2, "0")} + +
+

{step.title}

+

{step.body}

+
+
+ ))} +
+
+
+ + {/* Features */} +
+
+ +
+ {FEATURES.map((f) => ( +
+ + + +
+

{f.title}

+

{f.body}

+
+
+ ))} +
+
+
+ + {/* Pricing preview */} +
+
+ +
+ {PLAN_ORDER.map((key) => { + const plan = PLANS[key]; + return ( + + {plan.highlight && ( + + Most popular + + )} + +
+

{plan.name}

+

{plan.tagline}

+
+
+ {formatPrice(plan.priceMonthly)} + /mo +
+ +
    + {plan.bullets.slice(0, 5).map((b) => ( +
  • + + {b} +
  • + ))} +
+
+
+ ); + })} +
+

+ Full comparison on the{" "} + + pricing page + + . +

+
+
+ + {/* CTA */} +
+
+
+
+
+

+ Make your first episode today +

+

+ Spin up a fully produced episode on the free plan in a couple of minutes — then decide. +

+ +
+
+
+
+ + ); +} + +function SectionHeading({ + eyebrow, + title, + subtitle, +}: { + eyebrow: string; + title: string; + subtitle: string; +}) { + return ( +
+

{eyebrow}

+

{title}

+

{subtitle}

+
+ ); +} + +const STEPS = [ + { + icon: FileText, + title: "Configure your episode", + body: "Set the topic, tone, length, audience, and language. Pick a format: solo, interview, or multi-host.", + }, + { + icon: AudioLines, + title: "AI generates everything", + body: "GPT-4 writes the script, ElevenLabs records it with realistic voices, and DALL·E designs the cover art.", + }, + { + icon: ImageIcon, + title: "Fine-tune & publish", + body: "Edit the script, regenerate sections, download the MP3 and assets, and repurpose into blogs and social posts.", + }, +]; + +const FEATURES = [ + { icon: FileText, title: "AI script generation", body: "Structured, on-brand scripts tailored to your tone, format, and audience." }, + { icon: AudioLines, title: "Multi-voice audio", body: "14+ realistic voices and multi-speaker dialogue for interviews and panels." }, + { icon: ImageIcon, title: "AI cover art", body: "Eye-catching episode artwork generated to match your topic in one click." }, + { icon: Repeat, title: "Content repurposing", body: "Turn any episode into blog posts, social threads, and newsletters instantly." }, + { icon: ListChecks, title: "Series generator", body: "Plan an entire season — titles, topics, and episodes — from a single prompt." }, + { icon: Languages, title: "13+ languages", body: "Produce episodes for a global audience without re-recording anything." }, +]; diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx new file mode 100644 index 0000000..0d72399 --- /dev/null +++ b/app/(marketing)/pricing/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { PLAN_ORDER, PLANS } from "@/lib/billing/plans"; +import { formatPrice } from "@/lib/utils"; + +export const metadata: Metadata = { + title: "Pricing", + description: "Simple plans for every podcaster — start free and upgrade as you grow.", +}; + +export default function PricingPage() { + return ( +
+
+
+

Pricing

+

+ Start free, scale anytime +

+

+ Upgrade for higher limits and more features. Cancel anytime. Pay with Stripe + or PayPal. +

+
+ +
+ {PLAN_ORDER.map((key) => { + const plan = PLANS[key]; + return ( + + {plan.highlight && ( + + Most popular + + )} + +
+

{plan.name}

+

{plan.tagline}

+
+
+ {formatPrice(plan.priceMonthly)} + /mo +
+ +
    + {plan.bullets.map((b) => ( +
  • + + {b} +
  • + ))} +
+
+
+ ); + })} +
+ +

+ Prices in USD. Annual billing saves roughly two months. Usage limits reset on the first of + each month. +

+
+
+ ); +} diff --git a/app/(marketing)/privacy/page.tsx b/app/(marketing)/privacy/page.tsx new file mode 100644 index 0000000..b3fd8b7 --- /dev/null +++ b/app/(marketing)/privacy/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Privacy Policy" }; + +export default function PrivacyPage() { + return ( +
+

Privacy Policy

+

+ We collect the account information you provide (name, email) and the content you create to + operate PodcastYes. Episode prompts are sent to our AI providers (OpenAI and ElevenLabs) to + generate scripts, audio, and artwork. Generated assets are stored to deliver your episodes. + We do not sell your personal data. Payment processing is handled by Stripe and PayPal. +

+

+ This is placeholder copy — replace with your reviewed privacy policy before launch. +

+
+ ); +} diff --git a/app/(marketing)/terms/page.tsx b/app/(marketing)/terms/page.tsx new file mode 100644 index 0000000..79fa3b0 --- /dev/null +++ b/app/(marketing)/terms/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Terms of Service" }; + +export default function TermsPage() { + return ( +
+

Terms of Service

+

+ These terms govern your use of PodcastYes. By creating an account you agree to use the + service lawfully and to retain responsibility for the content you generate. AI-generated + scripts, audio, and artwork are provided as-is; review them before publishing. Subscriptions + renew automatically until cancelled, and usage limits reset monthly. +

+

+ This is placeholder copy — replace with your reviewed legal terms before launch. +

+
+ ); +} diff --git a/app/api/assets/[...key]/route.ts b/app/api/assets/[...key]/route.ts new file mode 100644 index 0000000..6e87ce8 --- /dev/null +++ b/app/api/assets/[...key]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { storage } from "@/lib/storage"; + +export const dynamic = "force-dynamic"; + +const CONTENT_TYPES: Record = { + mp3: "audio/mpeg", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + zip: "application/zip", + txt: "text/plain; charset=utf-8", +}; + +/** + * Serve a stored asset by key after verifying the requester owns the episode + * (or is an admin). Private MP3 downloads flow through here; public cover art is + * served directly by nginx from /media. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ key: string[] }> } +) { + const { key: segments } = await params; + const key = segments.join("/"); + + const session = await getServerSession(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + // Resolve the owning episode from the asset record so we can authorize. + const [audio, art] = await Promise.all([ + prisma.audioAsset.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }), + prisma.coverArt.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }), + ]); + const ownerId = audio?.episode.userId ?? art?.episode.userId; + if (!ownerId) return new Response("Not found", { status: 404 }); + + const isOwner = ownerId === session.user.id; + const isAdmin = session.user.role === "admin"; + if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 }); + + const exists = await storage().exists(key); + if (!exists) return new Response("Not found", { status: 404 }); + + const data = await storage().get(key); + const ext = key.split(".").pop()?.toLowerCase() ?? ""; + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + + const download = req.nextUrl.searchParams.get("download"); + const filename = key.split("/").pop() ?? "asset"; + + return new Response(data as BodyInit, { + headers: { + "Content-Type": contentType, + "Content-Length": String(data.byteLength), + "Cache-Control": "private, max-age=3600", + ...(download + ? { "Content-Disposition": `attachment; filename="${filename}"` } + : {}), + }, + }); +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..383f4bf --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth/auth"; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/app/api/episodes/[id]/stream/route.ts b/app/api/episodes/[id]/stream/route.ts new file mode 100644 index 0000000..0d9bb44 --- /dev/null +++ b/app/api/episodes/[id]/stream/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; +import { isTerminal } from "@/lib/episodes/status"; + +export const dynamic = "force-dynamic"; + +/** + * Server-Sent Events stream of an episode's generation status. Polls the row + * every 1.5s and emits on change until the episode reaches a terminal state. + * (LISTEN/NOTIFY is a future optimization; polling is simpler and robust.) + */ +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + const session = await getServerSession(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const ep = await prisma.episode.findUnique({ where: { id }, select: { userId: true } }); + if (!ep) return new Response("Not found", { status: 404 }); + if (ep.userId !== session.user.id && session.user.role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let lastSig = ""; + let stopped = false; + let pollTimer: ReturnType; + let pingTimer: ReturnType; + + const send = (data: unknown) => { + if (!stopped) controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + const stop = () => { + if (stopped) return; + stopped = true; + clearInterval(pollTimer); + clearInterval(pingTimer); + try { + controller.close(); + } catch { + /* already closed */ + } + }; + + const poll = async () => { + if (stopped) return; + const e = await prisma.episode.findUnique({ + where: { id }, + select: { status: true, stage: true, errorMessage: true }, + }); + if (!e) { + send({ status: "FAILED", error: "Episode not found" }); + stop(); + return; + } + const sig = `${e.status}:${e.stage ?? ""}`; + if (sig !== lastSig) { + lastSig = sig; + send({ status: e.status, stage: e.stage, error: e.errorMessage }); + } + if (isTerminal(e.status)) stop(); + }; + + send({ type: "open" }); + void poll(); + pollTimer = setInterval(poll, 1500); + pingTimer = setInterval(() => { + if (!stopped) controller.enqueue(encoder.encode(": ping\n\n")); + }, 15000); + + req.signal.addEventListener("abort", stop); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/v1/episodes/route.ts b/app/api/v1/episodes/route.ts new file mode 100644 index 0000000..cec62cc --- /dev/null +++ b/app/api/v1/episodes/route.ts @@ -0,0 +1,103 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { verifyApiKey, bearerKey } from "@/lib/apikeys"; +import { prisma } from "@/lib/db"; +import { getEffectivePlan } from "@/lib/billing/subscription"; +import { enforceLimit, LimitExceededError } from "@/lib/usage/limits"; +import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; +import { FORMAT_SPEAKERS } from "@/lib/episodes/options"; +import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; + +export const dynamic = "force-dynamic"; + +async function authorize(req: NextRequest) { + return verifyApiKey(bearerKey(req.headers.get("authorization"))); +} + +/** GET /api/v1/episodes — list the caller's episodes. */ +export async function GET(req: NextRequest) { + const auth = await authorize(req); + if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 }); + + const episodes = await prisma.episode.findMany({ + where: { userId: auth.userId }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { id: true, title: true, status: true, format: true, language: true, createdAt: true }, + }); + return Response.json({ episodes }); +} + +const createSchema = z.object({ + topic: z.string().min(10).max(2000), + title: z.string().max(120).optional(), + tone: z.string().default("Conversational"), + format: z.enum(["SOLO", "INTERVIEW", "MULTI_HOST"]).default("SOLO"), + language: z.string().min(2).max(5).default("en"), + targetLengthMin: z.number().int().min(1).max(180).default(5), + audience: z.string().max(200).optional(), +}); + +/** POST /api/v1/episodes — create + generate an episode. */ +export async function POST(req: NextRequest) { + const auth = await authorize(req); + if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 }); + + const rl = await rateLimit("api", auth.userId, LIMITS.api); + if (!rl.ok) { + return Response.json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } } + ); + } + + const parsed = createSchema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return Response.json({ error: parsed.error.issues[0]?.message ?? "Invalid body" }, { status: 400 }); + } + const data = parsed.data; + + const { plan } = await getEffectivePlan(auth.userId); + if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) { + return Response.json( + { error: `Plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes` }, + { status: 402 } + ); + } + try { + await enforceLimit(auth.userId, "script"); + await enforceLimit(auth.userId, "audio"); + } catch (err) { + if (err instanceof LimitExceededError) { + return Response.json({ error: `Monthly ${err.check.metric} limit reached` }, { status: 402 }); + } + throw err; + } + + const speakers = FORMAT_SPEAKERS[data.format].map((s, i) => ({ + speakerKey: s.speakerKey, + displayName: s.defaultName, + elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id, + })); + + const episode = await prisma.episode.create({ + data: { + userId: auth.userId, + title: data.title?.trim() || data.topic.slice(0, 60), + topic: data.topic, + tone: data.tone, + format: data.format, + language: data.language, + targetLengthMin: data.targetLengthMin, + audience: data.audience, + status: "QUEUED", + stage: "Queued for generation", + speakers: { create: speakers }, + jobs: { create: { type: "full", status: "queued" } }, + }, + }); + await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" }); + + return Response.json({ id: episode.id, status: episode.status }, { status: 201 }); +} diff --git a/app/api/webhooks/paypal/route.ts b/app/api/webhooks/paypal/route.ts new file mode 100644 index 0000000..8905582 --- /dev/null +++ b/app/api/webhooks/paypal/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; +import { verifyPaypalWebhook } from "@/lib/billing/paypal"; +import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal"; + +export const dynamic = "force-dynamic"; + +const SIG_HEADERS = [ + "paypal-auth-algo", + "paypal-cert-url", + "paypal-transmission-id", + "paypal-transmission-sig", + "paypal-transmission-time", +]; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const headers: Record = {}; + for (const h of SIG_HEADERS) headers[h] = req.headers.get(h) ?? undefined; + + const verified = await verifyPaypalWebhook(headers, body).catch(() => false); + if (!verified) { + console.error("[paypal webhook] verification failed"); + return new Response("Invalid signature", { status: 400 }); + } + + try { + await handlePaypalEvent(JSON.parse(body)); + } catch (err) { + console.error("[paypal webhook] handler error", err); + return new Response("Handler error", { status: 500 }); + } + return new Response("ok"); +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..ddd080a --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import type Stripe from "stripe"; +import { stripe } from "@/lib/billing/stripe"; +import { handleStripeEvent } from "@/lib/billing/webhooks/stripe"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + const signature = req.headers.get("stripe-signature"); + if (!secret || !signature) return new Response("Webhook not configured", { status: 400 }); + + const body = await req.text(); + let event: Stripe.Event; + try { + event = stripe().webhooks.constructEvent(body, signature, secret); + } catch (err) { + console.error("[stripe webhook] signature verification failed", err); + return new Response("Invalid signature", { status: 400 }); + } + + try { + await handleStripeEvent(event); + } catch (err) { + console.error("[stripe webhook] handler error", err); + return new Response("Handler error", { status: 500 }); + } + return new Response("ok"); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..be2ff35 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,104 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Wix-inspired: white base, near-black ink, single Wix-Blue accent */ + --background: 0 0% 100%; + --foreground: 0 0% 7%; + --card: 0 0% 100%; + --card-foreground: 0 0% 7%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 7%; + + /* INK — primary action is black */ + --primary: 0 0% 5%; + --primary-foreground: 0 0% 100%; + + /* WIX BLUE — links, focus, active states, accents */ + --brand: 217 100% 53%; + --brand-foreground: 0 0% 100%; + --brand-hover: 219 90% 46%; + + --secondary: 0 0% 96%; + --secondary-foreground: 0 0% 7%; + --muted: 0 0% 96%; + --muted-foreground: 0 0% 42%; + --accent: 0 0% 96%; + --accent-foreground: 0 0% 7%; + + --destructive: 4 86% 58%; + --destructive-foreground: 0 0% 100%; + --success: 157 100% 30%; + --warning: 24 100% 50%; + + --border: 0 0% 89%; + --input: 0 0% 89%; + --ring: 217 100% 53%; + --radius: 0.875rem; + } + + .dark { + --background: 0 0% 5%; + --foreground: 0 0% 98%; + --card: 0 0% 8%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 8%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 100%; + --primary-foreground: 0 0% 5%; + + --brand: 217 100% 60%; + --brand-foreground: 0 0% 100%; + --brand-hover: 217 100% 66%; + + --secondary: 0 0% 13%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 13%; + --muted-foreground: 0 0% 64%; + --accent: 0 0% 13%; + --accent-foreground: 0 0% 98%; + + --destructive: 4 80% 58%; + --destructive-foreground: 0 0% 100%; + --success: 157 70% 45%; + --warning: 24 100% 55%; + + --border: 0 0% 16%; + --input: 0 0% 16%; + --ring: 217 100% 60%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1, "ss01" 1; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + /* Headings use the Wix Madefor Display face by default */ + h1, h2, h3, .font-display { + font-family: var(--font-display), var(--font-sans), ui-sans-serif, system-ui, sans-serif; + letter-spacing: -0.02em; + } +} + +@layer utilities { + /* Subtle Wix-style radial wash for hero sections */ + .bg-hero-wash { + background-image: radial-gradient( + 60% 60% at 50% 0%, + hsl(var(--brand) / 0.1), + transparent 70% + ); + } + .text-balance { + text-wrap: balance; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..b09f9e7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { Wix_Madefor_Text, Wix_Madefor_Display } from "next/font/google"; +import { Toaster } from "sonner"; +import "./globals.css"; + +// Wix Madefor — the platform typeface (body + UI) +const madeforText = Wix_Madefor_Text({ + subsets: ["latin"], + variable: "--font-sans", + display: "swap", +}); + +// Wix Madefor Display — headlines (variable font, full weight range) +const madeforDisplay = Wix_Madefor_Display({ + subsets: ["latin"], + variable: "--font-display", + display: "swap", +}); + +export const metadata: Metadata = { + title: { + default: "PodcastYes — From topic idea to published podcast in minutes", + template: "%s · PodcastYes", + }, + description: + "PodcastYes is an all-in-one AI platform that writes your script, records realistic multi-voice audio, and designs cover art — turning a topic into a finished episode in minutes.", + metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"), + openGraph: { + title: "PodcastYes", + description: "Create scripted, narrated, illustrated podcasts with AI — no recording gear required.", + type: "website", + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..ff8dc48 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide-react" +} diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx new file mode 100644 index 0000000..0e21de0 --- /dev/null +++ b/components/admin/admin-sidebar.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Users, + CreditCard, + BarChart3, + ShieldAlert, + Activity, + Flag, + ScrollText, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +const NAV = [ + { label: "Overview", href: "/admin", icon: LayoutDashboard, exact: true }, + { label: "Users", href: "/admin/users", icon: Users }, + { label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard }, + { label: "AI usage & cost", href: "/admin/ai-usage", icon: BarChart3 }, + { label: "Moderation", href: "/admin/moderation", icon: ShieldAlert }, + { label: "System health", href: "/admin/health", icon: Activity }, + { label: "Feature flags", href: "/admin/flags", icon: Flag }, + { label: "Audit log", href: "/admin/audit", icon: ScrollText }, +]; + +export function AdminSidebar() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/components/admin/cost-chart.tsx b/components/admin/cost-chart.tsx new file mode 100644 index 0000000..082805d --- /dev/null +++ b/components/admin/cost-chart.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + CartesianGrid, +} from "recharts"; + +export interface CostPoint { + date: string; + openai: number; + elevenlabs: number; +} + +export function CostChart({ data }: { data: CostPoint[] }) { + return ( + + + + + `$${v}`} /> + `$${v.toFixed(2)}`} + contentStyle={{ fontSize: 12, borderRadius: 8 }} + /> + + {/* Wix-palette data series: Wix Blue + Deep Purple */} + + + + + ); +} diff --git a/components/admin/flags-client.tsx b/components/admin/flags-client.tsx new file mode 100644 index 0000000..24eb509 --- /dev/null +++ b/components/admin/flags-client.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Plus } from "lucide-react"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toggleFeatureFlagAction } from "@/app/(admin)/admin/actions"; + +interface Flag { + key: string; + enabled: boolean; +} + +export function FlagsClient({ flags }: { flags: Flag[] }) { + const router = useRouter(); + const [newKey, setNewKey] = useState(""); + + async function toggle(key: string, enabled: boolean) { + const res = await toggleFeatureFlagAction(key, enabled); + if (res.ok) router.refresh(); + else toast.error(res.error ?? "Failed"); + } + + async function create(e: React.FormEvent) { + e.preventDefault(); + const key = newKey.trim(); + if (!key) return; + const res = await toggleFeatureFlagAction(key, false); + if (res.ok) { + setNewKey(""); + toast.success("Flag created"); + router.refresh(); + } else { + toast.error(res.error ?? "Failed"); + } + } + + return ( +
+ + +
+ setNewKey(e.target.value.replace(/\s+/g, "_"))} + /> + +
+
+
+ + {flags.length === 0 ? ( +

No feature flags yet.

+ ) : ( +
+ {flags.map((f) => ( +
+ {f.key} + toggle(f.key, v)} /> +
+ ))} +
+ )} +
+ ); +} diff --git a/components/admin/moderation-actions.tsx b/components/admin/moderation-actions.tsx new file mode 100644 index 0000000..ea182b8 --- /dev/null +++ b/components/admin/moderation-actions.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { reviewContentFlagAction } from "@/app/(admin)/admin/actions"; + +export function ModerationActions({ flagId }: { flagId: string }) { + const router = useRouter(); + async function act(status: "reviewed" | "removed") { + const res = await reviewContentFlagAction(flagId, status); + if (res.ok) { + toast.success("Updated"); + router.refresh(); + } else { + toast.error(res.error ?? "Failed"); + } + } + return ( +
+ + +
+ ); +} diff --git a/components/admin/users-table.tsx b/components/admin/users-table.tsx new file mode 100644 index 0000000..ebfe718 --- /dev/null +++ b/components/admin/users-table.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { MoreHorizontal, ShieldCheck, ShieldOff, Ban, UserCheck } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { banUserAction, setRoleAction } from "@/app/(admin)/admin/actions"; + +export interface AdminUser { + id: string; + name: string; + email: string; + role: string; + banned: boolean; + plan: string; + createdAt: string; +} + +export function UsersTable({ users }: { users: AdminUser[] }) { + const router = useRouter(); + + async function run(action: () => Promise<{ ok: boolean; error?: string }>, msg: string) { + const res = await action(); + if (res.ok) { + toast.success(msg); + router.refresh(); + } else { + toast.error(res.error ?? "Failed"); + } + } + + return ( +
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
UserPlanRoleStatusJoined +
+

{u.name}

+

{u.email}

+
{u.plan} + {u.role === "admin" ? admin : user} + + {u.banned ? banned : active} + {new Date(u.createdAt).toLocaleDateString()} + + + + + + {u.role === "admin" ? ( + run(() => setRoleAction(u.id, "user"), "Role updated")}> + Revoke admin + + ) : ( + run(() => setRoleAction(u.id, "admin"), "Role updated")}> + Make admin + + )} + {u.banned ? ( + run(() => banUserAction(u.id, false), "User unbanned")}> + Unban + + ) : ( + run(() => banUserAction(u.id, true), "User banned")} + > + Ban + + )} + + +
+
+ ); +} diff --git a/components/app/api-keys-client.tsx b/components/app/api-keys-client.tsx new file mode 100644 index 0000000..44818cd --- /dev/null +++ b/components/app/api-keys-client.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Copy, KeyRound, Trash2, Check } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { createApiKeyAction, revokeApiKeyAction } from "@/app/(app)/api-keys/actions"; + +interface KeyRow { + id: string; + name: string; + prefix: string; + lastUsedAt: string | null; + createdAt: string; +} + +export function ApiKeysClient({ keys }: { keys: KeyRow[] }) { + const router = useRouter(); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [newKey, setNewKey] = useState(null); + + async function create(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + const res = await createApiKeyAction(name); + setCreating(false); + if (!res.ok || !res.key) { + toast.error(res.error ?? "Could not create"); + return; + } + setNewKey(res.key); + setName(""); + router.refresh(); + } + + async function revoke(id: string) { + if (!confirm("Revoke this key? Apps using it will stop working.")) return; + const res = await revokeApiKeyAction(id); + if (res.ok) { + toast.success("Key revoked"); + router.refresh(); + } else { + toast.error(res.error ?? "Could not revoke"); + } + } + + return ( +
+ {newKey && ( + + +

Your new API key — copy it now, it won't be shown again.

+
+ {newKey} + +
+
+
+ )} + + + +
+ setName(e.target.value)} + /> + +
+
+
+ + {keys.length === 0 ? ( +

No API keys yet.

+ ) : ( +
+ {keys.map((k) => ( +
+
+

{k.name}

+

+ {k.prefix} · created {new Date(k.createdAt).toLocaleDateString()} + {k.lastUsedAt ? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}` : " · never used"} +

+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/components/app/audio-player.tsx b/components/app/audio-player.tsx new file mode 100644 index 0000000..dcf9e7d --- /dev/null +++ b/components/app/audio-player.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +function formatDuration(sec?: number | null): string { + if (!sec) return ""; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +export function AudioPlayer({ + storageKey, + durationSec, +}: { + storageKey: string; + durationSec?: number | null; +}) { + const src = `/api/assets/${storageKey}`; + return ( +
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+ ); +} diff --git a/components/app/billing-client.tsx b/components/app/billing-client.tsx new file mode 100644 index 0000000..cdd2320 --- /dev/null +++ b/components/app/billing-client.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Check, Loader2, CreditCard, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn, formatPrice } from "@/lib/utils"; +import { PLAN_ORDER, PLANS, type PlanKey } from "@/lib/billing/plans"; +import type { BillingInterval } from "@/lib/billing/catalog"; +import { + startStripeCheckoutAction, + startPaypalCheckoutAction, + openStripePortalAction, + cancelSubscriptionAction, +} from "@/app/(app)/billing/actions"; + +interface SubInfo { + provider: string; + status: string; + cancelAtPeriodEnd: boolean | null; + periodEnd: string | null; +} + +export function BillingClient({ + currentPlan, + subscription, + stripeConfigured, + paypalConfigured, +}: { + currentPlan: PlanKey; + subscription: SubInfo | null; + stripeConfigured: boolean; + paypalConfigured: boolean; +}) { + const router = useRouter(); + const [interval, setInterval] = useState("month"); + const [busy, setBusy] = useState(null); + + async function go(action: () => Promise<{ ok: boolean; url?: string; error?: string }>, tag: string) { + setBusy(tag); + const res = await action(); + if (res.ok && res.url) { + window.location.href = res.url; + return; + } + setBusy(null); + if (res.ok) { + toast.success("Done"); + router.refresh(); + } else { + toast.error(res.error ?? "Something went wrong"); + } + } + + return ( +
+ {subscription && currentPlan !== "free" && ( + + +
+
+ {PLANS[currentPlan].name} plan + + {subscription.cancelAtPeriodEnd ? "Cancels at period end" : subscription.status} + + {subscription.provider} +
+ {subscription.periodEnd && ( +

+ {subscription.cancelAtPeriodEnd ? "Access until" : "Renews"}{" "} + {new Date(subscription.periodEnd).toLocaleDateString()} +

+ )} +
+
+ {subscription.provider === "stripe" && ( + + )} + {!subscription.cancelAtPeriodEnd && ( + + )} +
+
+
+ )} + +
+ +
+ +
+ {PLAN_ORDER.map((key) => { + const plan = PLANS[key]; + const isCurrent = key === currentPlan; + const price = interval === "year" ? plan.priceYearly : plan.priceMonthly; + return ( + + {plan.highlight && ( + + Most popular + + )} + +
+

{plan.name}

+

{plan.tagline}

+
+
+ {formatPrice(price)} + /{interval === "year" ? "yr" : "mo"} +
+ + {isCurrent ? ( + + ) : key === "free" ? ( + + ) : ( +
+ {stripeConfigured && ( + + )} + {paypalConfigured && ( + + )} + {!stripeConfigured && !paypalConfigured && ( +

Billing not configured

+ )} +
+ )} + +
    + {plan.bullets.slice(0, 5).map((b) => ( +
  • + + {b} +
  • + ))} +
+
+
+ ); + })} +
+
+ ); +} + +function IntervalToggle({ + interval, + onChange, +}: { + interval: BillingInterval; + onChange: (i: BillingInterval) => void; +}) { + return ( +
+ {(["month", "year"] as const).map((i) => ( + + ))} +
+ ); +} diff --git a/components/app/episode-actions.tsx b/components/app/episode-actions.tsx new file mode 100644 index 0000000..7d0314a --- /dev/null +++ b/components/app/episode-actions.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { MoreVertical, ImageIcon, RefreshCw, Trash2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { regenerateAction, deleteEpisodeAction } from "@/app/(app)/episodes/actions"; + +export function EpisodeActions({ episodeId }: { episodeId: string }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + + async function regen(type: "art" | "full") { + setBusy(true); + const res = await regenerateAction(episodeId, type); + setBusy(false); + if (res.ok) { + toast.success(type === "art" ? "Regenerating cover art…" : "Regenerating episode…"); + router.refresh(); + } else { + toast.error(res.error ?? "Could not regenerate"); + } + } + + async function del() { + if (!confirm("Delete this episode? This cannot be undone.")) return; + const res = await deleteEpisodeAction(episodeId); + if (res.ok) { + toast.success("Episode deleted"); + router.push("/episodes"); + router.refresh(); + } else { + toast.error(res.error ?? "Could not delete"); + } + } + + return ( + + + + + + regen("art")}> + Regenerate cover art + + regen("full")}> + Regenerate everything + + + + Delete episode + + + + ); +} diff --git a/components/app/episode-card.tsx b/components/app/episode-card.tsx new file mode 100644 index 0000000..b4659eb --- /dev/null +++ b/components/app/episode-card.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { Mic2 } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { EpisodeStatusBadge } from "./episode-status-badge"; + +export interface EpisodeCardData { + id: string; + title: string; + status: string; + format: string; + language: string; + createdAt: Date; + coverArtKey?: string | null; +} + +export function EpisodeCard({ episode }: { episode: EpisodeCardData }) { + return ( + + +
+ {episode.coverArtKey ? ( + // eslint-disable-next-line @next/next/no-img-element + {episode.title} + ) : ( +
+ +
+ )} +
+ +
+
+
+

{episode.title}

+

+ {episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "} + {episode.createdAt.toLocaleDateString()} +

+
+
+ + ); +} diff --git a/components/app/episode-status-badge.tsx b/components/app/episode-status-badge.tsx new file mode 100644 index 0000000..9776ee5 --- /dev/null +++ b/components/app/episode-status-badge.tsx @@ -0,0 +1,26 @@ +import { Loader2 } from "lucide-react"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; + +// Keyed by the Prisma EpisodeStatus enum values (kept as strings to avoid +// importing the Prisma client into client bundles). +const MAP: Record = { + DRAFT: { label: "Draft", variant: "secondary" }, + QUEUED: { label: "Queued", variant: "secondary", spin: true }, + SCRIPTING: { label: "Writing script", variant: "warning", spin: true }, + SYNTHESIZING: { label: "Recording audio", variant: "warning", spin: true }, + STITCHING: { label: "Mixing audio", variant: "warning", spin: true }, + ART: { label: "Designing art", variant: "warning", spin: true }, + SAVING: { label: "Finalizing", variant: "warning", spin: true }, + READY: { label: "Ready", variant: "success" }, + FAILED: { label: "Failed", variant: "destructive" }, +}; + +export function EpisodeStatusBadge({ status }: { status: string }) { + const config = MAP[status] ?? { label: status, variant: "secondary" as const }; + return ( + + {config.spin && } + {config.label} + + ); +} diff --git a/components/app/episode-wizard.tsx b/components/app/episode-wizard.tsx new file mode 100644 index 0000000..272b2b0 --- /dev/null +++ b/components/app/episode-wizard.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, ArrowRight, Loader2, Sparkles, User } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { TONES, FORMATS, LANGUAGES, LENGTH_OPTIONS, FORMAT_SPEAKERS } from "@/lib/episodes/options"; +import { VOICE_CATALOG, DEFAULT_VOICE_IDS } from "@/lib/ai/voices"; +import { createEpisodeAction, type CreateEpisodeInput } from "@/app/(app)/episodes/actions"; + +type Format = "SOLO" | "INTERVIEW" | "MULTI_HOST"; +interface SpeakerState { + speakerKey: string; + displayName: string; + elevenVoiceId: string; +} + +function defaultSpeakers(format: Format): SpeakerState[] { + return FORMAT_SPEAKERS[format].map((s, i) => ({ + speakerKey: s.speakerKey, + displayName: s.defaultName, + elevenVoiceId: + DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id, + })); +} + +export function EpisodeWizard({ maxMinutes }: { maxMinutes: number }) { + const router = useRouter(); + const [step, setStep] = useState(1); + const [submitting, setSubmitting] = useState(false); + + const [title, setTitle] = useState(""); + const [topic, setTopic] = useState(""); + const [tone, setTone] = useState(TONES[0]); + const [format, setFormat] = useState("SOLO"); + const [language, setLanguage] = useState("en"); + const [length, setLength] = useState(5); + const [audience, setAudience] = useState(""); + const [speakers, setSpeakers] = useState(defaultSpeakers("SOLO")); + + const lengths = useMemo(() => LENGTH_OPTIONS.filter((l) => l <= maxMinutes), [maxMinutes]); + + function changeFormat(f: Format) { + setFormat(f); + setSpeakers(defaultSpeakers(f)); + } + + function updateSpeaker(idx: number, patch: Partial) { + setSpeakers((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s))); + } + + function next() { + if (step === 1 && topic.trim().length < 10) { + toast.error("Please describe your topic in a bit more detail."); + return; + } + setStep((s) => Math.min(3, s + 1)); + } + + async function submit() { + setSubmitting(true); + const input: CreateEpisodeInput = { + title: title.trim() || undefined, + topic: topic.trim(), + tone, + format, + language, + targetLengthMin: length, + audience: audience.trim() || undefined, + speakers, + }; + const res = await createEpisodeAction(input); + if (!res.ok) { + toast.error(res.error); + setSubmitting(false); + return; + } + toast.success("Generating your episode…"); + router.push(`/episodes/${res.episodeId}`); + } + + return ( +
+ + + + + {step === 1 && ( + <> + +