Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+51
View File
@@ -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 <noreply@podcastyes.app>"
# ─────────────────────────── Worker ─────────────────────────
# Max concurrent audio-generation jobs on the worker process (keep low on a shared VPS).
WORKER_CONCURRENCY="2"
+41
View File
@@ -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
+223
View File
@@ -0,0 +1,223 @@
# PodcastYes — Design System
> A Wix.cominspired 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, tightlytracked headlines. Generous whitespace.
Let one idea own each section. Marketing reads like a magazine, not a SaaS dashboard.
2. **High contrast, mostly monochrome.** Nearblack 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 lightgray (`#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, inputsonwhite |
| **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 WixBlue 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)` (4472px) | 800 | `-0.03em` | Display font, `leading-[1.05]` |
| H1 (page) | `2.253rem` (3648px) | 700 | `-0.02em` | Display font |
| H2 (section) | `1.752.25rem` (2836px)| 700 | `-0.02em` | Display font |
| H3 | `1.25rem` (20px) | 600 | `-0.01em` | |
| Lead paragraph | `1.1251.25rem` (1820px)| 400 | normal | `text-muted-foreground` |
| Body | `1rem` (16px) | 400 | normal | base UI = 1416px |
| 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.051.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 (`<Button>`)
Pill shape, Madefor, weight 600, `transition`. Sizes: `sm h-9`, `default h-11`, `lg h-12`/`px-8`.
| Variant | Style |
| ------------ | ----- |
| `default` | **Black** fill, white text, `rounded-full`, `hover:bg-black/85`, soft shadow. Primary CTA. |
| `brand` | **Wix Blue** fill, white text, `rounded-full`, `hover:bg-[#0B57E0]`. Use for the single hero/marketing accent action when black isn't wanted. |
| `secondary` | `secondary` gray fill, ink text, `rounded-full`, `hover:bg-black/[.06]`. |
| `outline` | White fill, `border` (1.5px) ink/border color, ink text, `rounded-full`, hover gray fill. |
| `ghost` | Transparent, ink text, `hover:bg-secondary`. |
| `link` | WixBlue text, underline on hover, no padding. |
| `destructive`| `destructive` fill, white text, `rounded-full`. |
Icon buttons: `rounded-full`, square. Always include the blue focus ring.
### Cards (`<Card>`)
`rounded-2xl border bg-card shadow-sm`. Interactive cards add `transition hover:shadow-md hover:-translate-y-0.5`.
Highlighted/"most popular" card: `border-2 border-foreground` or `ring-2 ring-brand` + `shadow-lg`.
### Inputs / textarea / select
`h-11 rounded-xl border border-input bg-white px-4 text-base`. Placeholder = muted.
Focus: `ring-2 ring-brand border-transparent`. Labels: 14px weight 600, `mb-2`.
### Badges / chips
`rounded-full px-3 py-1 text-xs font-semibold`.
- `default` → ink fill / white text.
- `brand` → blue tint `bg-brand/10 text-brand`.
- `secondary` → gray fill.
- `success / warning / destructive` → tinted (`bg-x/12 text-x`).
Eyebrow chips on marketing: `bg-secondary` pill with a blue dot/`Sparkles` and uppercase label.
### Navigation
- **Marketing header:** white, hairline bottom border, `backdrop-blur`. Black wordmark
with blue mic mark. Nav links muted → ink on hover. Right side: `ghost` "Log in" +
black pill "Get Started".
- **App sidebar:** white, hairline right border. Item = `rounded-full px-4 py-2.5`.
Active = `bg-brand/10 text-brand font-semibold`; idle = muted, `hover:bg-secondary`.
- **Active link accent is always Wix Blue.**
### Pricing cards
White cards, big display price (weight 800). Highlighted plan: `ring-2 ring-brand` +
floating blue "Most popular" pill + `shadow-lg`. Feature list uses blue `Check` icons.
### Footer
`bg-secondary` (`#F4F4F4`), hairline top border, multicolumn link lists, muted labels,
ink wordmark. Calm and quiet.
---
## 6. Iconography & imagery
- **Icons:** `lucide-react`, `1.5` stroke, `size-4`/`size-5`. Inherit text color; use blue
only for accent/active. Icon "chips" = `rounded-2xl bg-secondary` (neutral) or
`bg-brand/10 text-brand` (accent) tile.
- **Brand mark:** mic glyph in a `rounded-2xl` tile — blue tile / white glyph on light, or
black tile on accent surfaces.
- **Imagery:** large, rounded (`rounded-3xl`), fullbleed where possible. Generous.
---
## 7. Motion
- Transitions `150200ms ease-out` for color/shadow/transform.
- Hover lift on cards: `-translate-y-0.5`. Buttons: shade shift only.
- Respect `prefers-reduced-motion`. Keep it subtle — Wix is smooth, not bouncy.
---
## 8. Do / Don't
- ✅ Black primary pill + WixBlue accents. ❌ Purple/gradient primary buttons.
- ✅ One blue accent per view. ❌ Rainbow of brand colors in core UI.
- ✅ Big tight headlines in Madefor Display. ❌ Small timid headings.
- ✅ Hairline borders + soft shadows + whitespace. ❌ Heavy borders, boxesinboxes.
- ✅ Pill buttons & chips, `rounded-2xl` cards. ❌ Sharp corners / `rounded-md` chrome.
- ✅ Blue focus ring everywhere. ❌ Default browser outline or no focus state.
+88
View File
@@ -0,0 +1,88 @@
# 🎙️ PodcastYes
An all-in-one AI platform that takes you from a topic idea to a finished, publishable
podcast episode in minutes — it writes the **script** (GPT-4), records realistic
**multi-voice audio** (ElevenLabs), and designs the **cover art** (DALL·E), then lets
you fine-tune, download, and repurpose the result.
## Stack
| Concern | Choice |
| --- | --- |
| Framework | Next.js (App Router) + TypeScript |
| UI | Tailwind CSS + shadcn/ui + Recharts |
| Database | PostgreSQL + Prisma |
| Auth | Better Auth (email/password + Google, admin + organization plugins) |
| Queue | **pg-boss** (Postgres-backed, no Redis) + a PM2 worker process |
| Storage | Local disk (swappable `StorageProvider` → S3/R2) |
| AI | OpenAI (GPT-4 + DALL·E), ElevenLabs (TTS + dialogue) |
| Billing | **Stripe and PayPal** → one unified `Subscription` model |
| Email | Resend |
| Deploy | Plesk / Linux VPS (see [`deploy/README.md`](deploy/README.md)) |
## Architecture
```
Browser ──► Next.js web (PM2) ──enqueue──► Postgres (app data + pgboss queue)
│ SSE status ▲ claim job (SKIP LOCKED)
▼ │
/api/assets (private mp3) Worker (PM2, ffmpeg)
script → segment → ElevenLabs
→ ffmpeg stitch → DALL·E → save → meter
Local disk storage/{mp3,art}
```
## Local development
```bash
npm install
cp .env.example .env # fill DATABASE_URL, BETTER_AUTH_SECRET, OPENAI_API_KEY, ELEVENLABS_API_KEY…
npx prisma migrate dev # create tables
npm run db:seed # seed the plan catalog
# Two processes:
npm run dev # web (http://localhost:3000)
npm run worker:dev # generation worker (needs ffmpeg on PATH)
```
`ffmpeg` must be installed and on `PATH` for audio stitching
(`brew install ffmpeg` / `apt install ffmpeg` / `choco install ffmpeg`).
Make yourself an admin after signing up:
```bash
npm run make-admin you@email.com # then visit /admin
```
## Key directories
```
app/(marketing) public landing + pricing
app/(auth) sign-in / sign-up / password reset
app/(app) user dashboard (episodes, wizard, usage, billing, team, api-keys, settings)
app/(admin) admin dashboard (users, subscriptions, AI cost, moderation, health, flags, audit)
app/api auth, webhooks/{stripe,paypal}, episodes/[id]/stream (SSE), assets, v1 (API)
lib/ai provider abstraction + pipeline (generate-episode, segment, stitch, repurpose)
lib/billing plans, stripe, paypal, catalog, unified subscription writer, webhooks
lib/queue pg-boss client + job definitions
lib/storage StorageProvider interface + local-disk impl
worker/ pg-boss consumer (runs the generation pipeline)
```
## How generation works
1. The 3-step wizard creates an `Episode (QUEUED)` + speaker config, then enqueues a
pg-boss job (after `enforceLimit` checks the plan).
2. The worker runs the pipeline, updating `Episode.status` at each stage.
3. The episode page subscribes to `/api/episodes/[id]/stream` (SSE) and shows a live
stepper, then renders the script editor, audio player, and cover art when `READY`.
Long scripts are chunked to stay under ElevenLabs' ~2k-char dialogue limit, synthesized
per segment, then concatenated and loudness-normalized with ffmpeg.
## Deployment
See [`deploy/README.md`](deploy/README.md) for the full Plesk / Linux runbook
(PM2, nginx, SSL, webhooks, backups).
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
async function adminSession() {
const s = await getServerSession();
if (!s || s.user.role !== "admin") return null;
return s;
}
async function audit(
actorId: string,
action: string,
target?: string,
metadata?: Prisma.InputJsonValue
) {
await prisma.auditLog.create({
data: { actorId, actorType: "admin", action, target, metadata },
});
}
export async function banUserAction(userId: string, ban: boolean): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
if (userId === s.user.id) return { ok: false, error: "You can't ban yourself." };
await prisma.user.update({ where: { id: userId }, data: { banned: ban } });
// Revoke sessions on ban so access stops immediately.
if (ban) await prisma.session.deleteMany({ where: { userId } });
await audit(s.user.id, ban ? "user.ban" : "user.unban", userId);
revalidatePath("/admin/users");
return { ok: true };
}
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.user.update({ where: { id: userId }, data: { role } });
await audit(s.user.id, "user.role", userId, { role });
revalidatePath("/admin/users");
return { ok: true };
}
export async function toggleFeatureFlagAction(
key: string,
enabled: boolean
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
await audit(s.user.id, "flag.toggle", key, { enabled });
revalidatePath("/admin/flags");
return { ok: true };
}
export async function reviewContentFlagAction(
flagId: string,
status: "reviewed" | "removed"
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.contentFlag.update({ where: { id: flagId }, data: { status, reviewedBy: s.user.id } });
await audit(s.user.id, "content.review", flagId, { status });
revalidatePath("/admin/moderation");
return { ok: true };
}
+94
View File
@@ -0,0 +1,94 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CostChart, type CostPoint } from "@/components/admin/cost-chart";
export const metadata: Metadata = { title: "Admin · AI usage" };
export default async function AdminAiUsagePage() {
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
const logs = await prisma.aiCostLog.findMany({
where: { createdAt: { gte: since } },
select: { provider: true, operation: true, costUsd: true, createdAt: true },
});
// Daily totals by provider for the last 14 days.
const byDay = new Map<string, CostPoint>();
for (let i = 13; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
byDay.set(key, { date: key, openai: 0, elevenlabs: 0 });
}
let totalOpenai = 0;
let totalEleven = 0;
const byOperation: Record<string, number> = {};
for (const log of logs) {
const d = log.createdAt;
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
const point = byDay.get(key);
const cost = Number(log.costUsd);
if (point) {
if (log.provider === "elevenlabs") point.elevenlabs += cost;
else point.openai += cost;
}
if (log.provider === "elevenlabs") totalEleven += cost;
else totalOpenai += cost;
byOperation[log.operation] = (byOperation[log.operation] ?? 0) + cost;
}
const data = Array.from(byDay.values()).map((p) => ({
date: p.date,
openai: Math.round(p.openai * 100) / 100,
elevenlabs: Math.round(p.elevenlabs * 100) / 100,
}));
return (
<>
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<Stat label="OpenAI (14d)" value={`$${totalOpenai.toFixed(2)}`} />
<Stat label="ElevenLabs (14d)" value={`$${totalEleven.toFixed(2)}`} />
<Stat label="Total (14d)" value={`$${(totalOpenai + totalEleven).toFixed(2)}`} />
</div>
<Card>
<CardHeader>
<CardTitle>Daily AI spend</CardTitle>
</CardHeader>
<CardContent>
<CostChart data={data} />
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle>By operation</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{["script", "audio", "art", "repurpose"].map((op) => (
<div key={op} className="rounded-lg border p-3">
<p className="text-xs capitalize text-muted-foreground">{op}</p>
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
</div>
))}
</div>
</CardContent>
</Card>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
</CardContent>
</Card>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Audit log" };
export default async function AdminAuditPage() {
const logs = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 100,
include: { actor: { select: { email: true } } },
});
return (
<>
<PageHeader title="Audit log" description="Recent administrative actions." />
{logs.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No audit entries yet.</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="p-3 font-medium">When</th>
<th className="p-3 font-medium">Actor</th>
<th className="p-3 font-medium">Action</th>
<th className="p-3 font-medium">Target</th>
</tr>
</thead>
<tbody className="divide-y">
{logs.map((l) => (
<tr key={l.id} className="hover:bg-muted/20">
<td className="p-3 text-muted-foreground">{l.createdAt.toLocaleString()}</td>
<td className="p-3">{l.actor?.email ?? l.actorType}</td>
<td className="p-3">
<Badge variant="outline">{l.action}</Badge>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">{l.target ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { FlagsClient } from "@/components/admin/flags-client";
export const metadata: Metadata = { title: "Admin · Feature flags" };
export default async function AdminFlagsPage() {
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
return (
<>
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Activity, CheckCircle2, AlertTriangle } from "lucide-react";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Health" };
export default async function AdminHealthPage() {
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const [jobGroups, episodeGroups, recentFailures, queuePending] = await Promise.all([
prisma.generationJob.groupBy({ by: ["status"], _count: true }),
prisma.episode.groupBy({ by: ["status"], _count: true }),
prisma.episode.count({ where: { status: "FAILED", updatedAt: { gte: dayAgo } } }),
prisma.generationJob.count({ where: { status: { in: ["queued", "running"] } } }),
]);
const jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
const queueHealthy = recentFailures < 5;
return (
<>
<PageHeader title="System health" description="Generation pipeline and queue status." />
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Activity className="h-4 w-4" /> Queue
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<p className="font-display text-3xl font-extrabold tracking-tight">{queuePending}</p>
<Badge variant={queueHealthy ? "success" : "warning"}>
{queueHealthy ? "Healthy" : "Degraded"}
</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Failures (24h)</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
{recentFailures === 0 ? (
<CheckCircle2 className="h-5 w-5 text-success" />
) : (
<AlertTriangle className="h-5 w-5 text-warning" />
)}
<p className="font-display text-3xl font-extrabold tracking-tight">{recentFailures}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Running jobs</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{jobCounts["running"] ?? 0}</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Generation jobs</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{["queued", "running", "completed", "failed"].map((s) => (
<div key={s} className="flex items-center justify-between text-sm">
<span className="capitalize text-muted-foreground">{s}</span>
<span className="font-medium">{jobCounts[s] ?? 0}</span>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Episodes by status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{episodeGroups.map((g) => (
<div key={g.status} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{g.status}</span>
<span className="font-medium">{g._count}</span>
</div>
))}
</CardContent>
</Card>
</div>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { ShieldCheck } from "lucide-react";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ModerationActions } from "@/components/admin/moderation-actions";
export const metadata: Metadata = { title: "Admin · Moderation" };
export default async function AdminModerationPage() {
const flags = await prisma.contentFlag.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
include: { episode: { select: { id: true, title: true } } },
});
return (
<>
<PageHeader title="Content moderation" description="Review flagged episodes." />
{flags.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<ShieldCheck className="h-10 w-10 text-success" />
<p className="font-medium">Nothing to review</p>
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{flags.map((f) => (
<Card key={f.id}>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div>
<p className="font-medium">{f.episode.title}</p>
<p className="text-sm text-muted-foreground">{f.reason}</p>
</div>
<ModerationActions flagId={f.id} />
</CardContent>
</Card>
))}
</div>
)}
</>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import { Users, CreditCard, Mic2, DollarSign, TrendingUp, AlertTriangle } from "lucide-react";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PLANS, PLAN_ORDER, type PlanKey } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
export const metadata: Metadata = { title: "Admin" };
export default async function AdminOverviewPage() {
const now = new Date();
const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const [userCount, newUsers, activeSubs, episodeCount, failedCount, spend] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.subscription.findMany({
where: { status: { in: ["active", "trialing"] } },
select: { plan: true },
}),
prisma.episode.count(),
prisma.episode.count({ where: { status: "FAILED" } }),
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { createdAt: { gte: startOfMonth } } }),
]);
const tierCounts: Record<string, number> = {};
let mrr = 0;
for (const sub of activeSubs) {
tierCounts[sub.plan] = (tierCounts[sub.plan] ?? 0) + 1;
mrr += PLANS[(sub.plan as PlanKey) in PLANS ? (sub.plan as PlanKey) : "free"].priceMonthly;
}
const aiSpend = Number(spend._sum.costUsd ?? 0);
const errorRate = episodeCount > 0 ? Math.round((failedCount / episodeCount) * 100) : 0;
return (
<>
<PageHeader title="Overview" description="Platform health at a glance." />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Kpi icon={DollarSign} label="MRR" value={formatPrice(mrr)} hint={`${activeSubs.length} active subs`} />
<Kpi icon={Users} label="Users" value={String(userCount)} hint={`+${newUsers} in 30 days`} />
<Kpi icon={Mic2} label="Episodes generated" value={String(episodeCount)} />
<Kpi icon={TrendingUp} label="AI spend (MTD)" value={`$${aiSpend.toFixed(2)}`} />
<Kpi icon={CreditCard} label="Paying customers" value={String(activeSubs.filter((s) => s.plan !== "free").length)} />
<Kpi icon={AlertTriangle} label="Episode error rate" value={`${errorRate}%`} />
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>Subscriptions by tier</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{PLAN_ORDER.map((key) => (
<div key={key} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium capitalize">{PLANS[key].name}</span>
<Badge variant="secondary">{tierCounts[key] ?? 0}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{formatPrice(PLANS[key].priceMonthly)}/mo</p>
</div>
))}
</div>
</CardContent>
</Card>
</>
);
}
function Kpi({
icon: Icon,
label,
value,
hint,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
hint?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
export const metadata: Metadata = { title: "Admin · Subscriptions" };
export default async function AdminSubscriptionsPage() {
const subs = await prisma.subscription.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
const refIds = subs.map((s) => s.referenceId);
const [users, orgs] = await Promise.all([
prisma.user.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true, email: true } }),
prisma.organization.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true } }),
]);
const nameByRef = new Map<string, string>();
for (const u of users) nameByRef.set(u.id, u.email);
for (const o of orgs) nameByRef.set(o.id, o.name);
const active = subs.filter((s) => ["active", "trialing"].includes(s.status));
const mrr = active.reduce((sum, s) => sum + (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0), 0);
const stripeCount = active.filter((s) => s.provider === "stripe").length;
const paypalCount = active.filter((s) => s.provider === "paypal").length;
return (
<>
<PageHeader title="Subscriptions" description="Revenue and active subscriptions." />
<div className="mb-6 grid gap-4 sm:grid-cols-4">
<Stat label="MRR" value={formatPrice(mrr)} />
<Stat label="ARR" value={formatPrice(mrr * 12)} />
<Stat label="Stripe" value={String(stripeCount)} />
<Stat label="PayPal" value={String(paypalCount)} />
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="p-3 font-medium">Customer</th>
<th className="p-3 font-medium">Plan</th>
<th className="p-3 font-medium">Provider</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Renews</th>
</tr>
</thead>
<tbody className="divide-y">
{subs.map((s) => (
<tr key={s.id} className="hover:bg-muted/20">
<td className="p-3">{nameByRef.get(s.referenceId) ?? s.referenceId}</td>
<td className="p-3 capitalize">{s.plan}</td>
<td className="p-3 capitalize">{s.provider}</td>
<td className="p-3">
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
</Badge>
</td>
<td className="p-3 text-muted-foreground">
{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}
</td>
</tr>
))}
{subs.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
No subscriptions yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
</CardContent>
</Card>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { UsersTable } from "@/components/admin/users-table";
export const metadata: Metadata = { title: "Admin · Users" };
export default async function AdminUsersPage() {
const [users, subs] = await Promise.all([
prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 200 }),
prisma.subscription.findMany({
where: { status: { in: ["active", "trialing"] } },
select: { referenceId: true, plan: true },
}),
]);
const planByRef = new Map(subs.map((s) => [s.referenceId, s.plan]));
return (
<>
<PageHeader title="Users" description={`${users.length} most recent users.`} />
<UsersTable
users={users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role ?? "user",
banned: !!u.banned,
plan: planByRef.get(u.id) ?? "free",
createdAt: u.createdAt.toISOString(),
}))}
/>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import { ShieldCheck, ArrowLeft } from "lucide-react";
import { requireAdmin } from "@/lib/auth/guards";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
import { UserMenu } from "@/components/app/user-menu";
import { Button } from "@/components/ui/button";
// Authed, DB-backed admin surface — never statically prerender.
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await requireAdmin();
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
<Link href="/admin" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-foreground text-background">
<ShieldCheck className="h-5 w-5" />
</span>
<span>PodcastYes Admin</span>
</Link>
<div className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">
<ArrowLeft className="h-4 w-4" /> Back to app
</Link>
</Button>
<UserMenu name={session.user.name} email={session.user.email} image={session.user.image} isAdmin />
</div>
</header>
<div className="flex flex-1">
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
<div className="sticky top-16">
<AdminSidebar />
</div>
</aside>
<main className="flex-1 bg-secondary/50">
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
</main>
</div>
</div>
);
}
+46
View File
@@ -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 };
}
+46
View File
@@ -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 (
<>
<PageHeader title="API keys" description="Programmatic access to the PodcastYes API." />
{!allowed ? (
<UpgradeGate
title="API access is a Pro feature"
description="Upgrade to Pro to create API keys and generate episodes programmatically."
requiredPlan="Pro"
/>
) : (
<ApiKeysClient
keys={(
await prisma.apiKey.findMany({
where: { userId: session.user.id, revokedAt: null },
orderBy: { createdAt: "desc" },
})
).map((k) => ({
id: k.id,
name: k.name,
prefix: k.prefix,
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
createdAt: k.createdAt.toISOString(),
}))}
/>
)}
</>
);
}
+115
View File
@@ -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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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) };
}
}
+56
View File
@@ -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 (
<>
<PageHeader title="Billing" description="Manage your plan and payment method." />
{status === "success" && (
<div className="mb-6 rounded-2xl border border-success/30 bg-success/10 px-4 py-3 text-sm font-medium text-success">
Payment received your plan will update momentarily once the provider confirms.
</div>
)}
{status === "cancel" && (
<div className="mb-6 rounded-2xl border border-warning/30 bg-warning/10 px-4 py-3 text-sm font-medium text-warning">
Checkout canceled. No changes were made.
</div>
)}
<BillingClient
currentPlan={currentPlan}
subscription={
sub
? {
provider: sub.provider,
status: sub.status,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
periodEnd: sub.periodEnd ? sub.periodEnd.toISOString() : null,
}
: null
}
stripeConfigured={isStripeConfigured()}
paypalConfigured={isPaypalConfigured()}
/>
</>
);
}
+131
View File
@@ -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 (
<>
<PageHeader
title={`Welcome back, ${firstName}`}
description="Spin up a fully produced episode in a couple of minutes."
action={
<Button asChild>
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Episodes created</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-4xl font-extrabold tracking-tight">{episodeCount}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Current plan</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<p className="font-display text-4xl font-extrabold capitalize tracking-tight">{plan.name}</p>
{key === "free" && (
<Button asChild size="sm" variant="outline">
<Link href="/billing">Upgrade</Link>
</Button>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Usage this month</CardTitle>
</CardHeader>
<CardContent>
<Button asChild size="sm" variant="outline">
<Link href="/usage">
View usage <ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
</div>
<Card className="mt-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent episodes</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/episodes">View all</Link>
</Button>
</CardHeader>
<CardContent>
{recent.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<Mic2 className="h-6 w-6" />
</span>
<div>
<p className="font-medium">No episodes yet</p>
<p className="text-sm text-muted-foreground">
Create your first AI-produced episode to get started.
</p>
</div>
<Button asChild>
<Link href="/episodes/new">
<Sparkles className="h-4 w-4" /> Create your first episode
</Link>
</Button>
</div>
) : (
<ul className="divide-y">
{recent.map((ep) => (
<li key={ep.id}>
<Link
href={`/episodes/${ep.id}`}
className="flex items-center justify-between gap-3 py-3 hover:opacity-80"
>
<div className="min-w-0">
<p className="truncate font-medium">{ep.title}</p>
<p className="text-xs text-muted-foreground">
{ep.format.replace("_", "-").toLowerCase()} ·{" "}
{ep.createdAt.toLocaleDateString()}
</p>
</div>
<EpisodeStatusBadge status={ep.status} />
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</>
);
}
+103
View File
@@ -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<string, string> = {};
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
return (
<>
<PageHeader
title={episode.title}
description={`${episode.format.replace("_", "-").toLowerCase()} · ${episode.language.toUpperCase()} · ${episode.targetLengthMin} min`}
action={!inProgress ? <EpisodeActions episodeId={episode.id} /> : undefined}
/>
{episode.status === "FAILED" || inProgress ? (
<GenerationProgress
episodeId={episode.id}
initialStatus={episode.status}
initialStage={episode.stage}
initialError={episode.errorMessage}
/>
) : (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card className="overflow-hidden">
<div className="aspect-square bg-muted">
{episode.coverArt ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/assets/${episode.coverArt.storageKey}`}
alt={episode.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<Mic2 className="h-10 w-10" />
</div>
)}
</div>
</Card>
{episode.audioAsset && (
<Card>
<CardContent className="pt-6">
<AudioPlayer
storageKey={episode.audioAsset.storageKey}
durationSec={episode.audioAsset.durationSec}
/>
</CardContent>
</Card>
)}
<Button asChild variant="outline" className="w-full">
<Link href={`/episodes/${episode.id}/repurpose`}>
<Repeat className="h-4 w-4" /> Repurpose content
</Link>
</Button>
</div>
<div className="lg:col-span-2">
{episode.script ? (
<ScriptEditor
key={episode.script.version}
episodeId={episode.id}
script={episode.script.content as unknown as StructuredScript}
speakerNames={speakerNames}
/>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
No script available.
</CardContent>
</Card>
)}
</div>
</div>
)}
</>
);
}
@@ -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<Format, Content> = { 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 (
<>
<Button asChild variant="ghost" size="sm" className="mb-2">
<Link href={`/episodes/${id}`}>
<ArrowLeft className="h-4 w-4" /> Back to episode
</Link>
</Button>
<PageHeader
title="Repurpose content"
description={`Turn "${episode.title}" into a blog post, social thread, or newsletter.`}
/>
<RepurposeClient episodeId={id} initial={initial} />
</>
);
}
+329
View File
@@ -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<typeof createSchema>;
export type CreateEpisodeResult =
| { ok: true; episodeId: string }
| { ok: false; error: string; limited?: boolean };
export async function createEpisodeAction(input: CreateEpisodeInput): Promise<CreateEpisodeResult> {
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<typeof repurposeScript>[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) + "…";
}
+21
View File
@@ -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 (
<>
<PageHeader
title="Create an episode"
description="Configure your episode and let the AI write, record, and design it."
/>
<EpisodeWizard maxMinutes={plan.limits.maxEpisodeMinutes} />
</>
);
}
+69
View File
@@ -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 (
<>
<PageHeader
title="Episodes"
description="Your AI-produced podcast library."
action={
<Button asChild>
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
}
/>
{episodes.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border py-16 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<Mic2 className="h-6 w-6" />
</span>
<div>
<p className="font-medium">No episodes yet</p>
<p className="text-sm text-muted-foreground">Create your first AI-produced episode.</p>
</div>
<Button asChild>
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{episodes.map((ep) => (
<EpisodeCard
key={ep.id}
episode={{
id: ep.id,
title: ep.title,
status: ep.status,
format: ep.format,
language: ep.language,
createdAt: ep.createdAt,
coverArtKey: ep.coverArt?.storageKey,
}}
/>
))}
</div>
)}
</>
);
}
+56
View File
@@ -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 (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
<Link href="/dashboard" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
<span className="hidden sm:inline">PodcastYes</span>
</Link>
<div className="flex items-center gap-3">
<Button asChild size="sm">
<Link href="/episodes/new">
<Plus className="h-4 w-4" /> New episode
</Link>
</Button>
<UserMenu
name={session.user.name}
email={session.user.email}
image={session.user.image}
isAdmin={isAdmin}
/>
</div>
</header>
<div className="flex flex-1">
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
<div className="sticky top-16">
<SidebarNav plan={plan} />
</div>
</aside>
<main className="flex-1 bg-secondary/50">
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
</main>
</div>
</div>
);
}
+56
View File
@@ -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 (
<>
<Button asChild variant="ghost" size="sm" className="mb-2">
<Link href="/series">
<ArrowLeft className="h-4 w-4" /> Back to series
</Link>
</Button>
<PageHeader title={series.title} description={series.description ?? undefined} />
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Planned episodes</h2>
<SeriesDetailClient seriesId={series.id} episodes={planned} />
{series.episodes.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Generated</h2>
<div className="space-y-2">
{series.episodes.map((ep) => (
<Link key={ep.id} href={`/episodes/${ep.id}`}>
<Card className="transition-shadow hover:shadow-md">
<CardContent className="flex items-center justify-between py-3">
<span className="truncate font-medium">{ep.title}</span>
<EpisodeStatusBadge status={ep.status} />
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)}
</>
);
}
+101
View File
@@ -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<typeof createSchema>
): 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 };
}
+76
View File
@@ -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 (
<>
<PageHeader title="Series generator" description="Plan a whole season at once." />
<UpgradeGate
title="Series generator is a Pro feature"
description="Upgrade to Pro to plan entire seasons and batch-generate episodes."
requiredPlan="Pro"
/>
</>
);
}
const series = await prisma.series.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
include: { _count: { select: { episodes: true } } },
});
return (
<>
<PageHeader
title="Series generator"
description="Plan a cohesive season, then generate each episode."
/>
<SeriesCreateForm />
{series.length > 0 && (
<div className="mt-8 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Your seasons</h2>
<div className="grid gap-3 sm:grid-cols-2">
{series.map((s) => (
<Link key={s.id} href={`/series/${s.id}`}>
<Card className="transition-shadow hover:shadow-md">
<CardContent className="flex items-center gap-3 py-4">
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-brand/10 text-brand">
<ListMusic className="h-5 w-5" />
</span>
<div className="min-w-0">
<p className="truncate font-medium">{s.title}</p>
<p className="text-xs text-muted-foreground">
{s.plannedCount} planned · {s._count.episodes} generated
</p>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)}
</>
);
}
+16
View File
@@ -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 (
<>
<PageHeader title="Settings" description="Manage your account." />
<SettingsClient name={session.user.name} email={session.user.email} />
</>
);
}
+51
View File
@@ -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<typeof brandingSchema>
): 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 };
}
+68
View File
@@ -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 (
<>
<PageHeader title="Team workspace" description="Collaborate with your team." />
<UpgradeGate
title="Team workspaces are an Agency feature"
description="Upgrade to Agency for a 5-seat workspace, white-label mode, and custom branding."
requiredPlan="Agency"
/>
</>
);
}
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 (
<>
<PageHeader title="Team workspace" description="Members, seats, and white-label branding." />
<TeamClient
org={org ? { id: org.id, name: org.name } : null}
members={members}
branding={
org?.branding
? {
brandName: org.branding.brandName,
primaryColor: org.branding.primaryColor,
logoUrl: org.branding.logoUrl,
removePoweredBy: org.branding.removePoweredBy,
}
: null
}
seats={plan.limits.seats}
/>
</>
);
}
+97
View File
@@ -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 (
<>
<PageHeader
title="Usage & limits"
description={`Resets on ${nextResetLabel()}.`}
action={
key !== "agency" ? (
<Button asChild>
<Link href="/billing">Upgrade plan</Link>
</Button>
) : undefined
}
/>
<div className="mb-6 flex items-center gap-2">
<span className="text-sm text-muted-foreground">Current plan</span>
<Badge className="capitalize">{plan.name}</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{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 (
<Card key={m.key}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<m.icon className="h-4 w-4" /> {m.label}
</CardTitle>
{atLimit && <Badge variant="warning">Limit reached</Badge>}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline justify-between">
<span className="font-display text-3xl font-extrabold tracking-tight">{used}</span>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
{unlimited ? (
<>
<InfinityIcon className="h-4 w-4" /> Unlimited
</>
) : (
<>of {limit}</>
)}
</span>
</div>
{!unlimited && (
<Progress
value={pct}
indicatorClassName={atLimit ? "bg-warning" : undefined}
/>
)}
</CardContent>
</Card>
);
})}
</div>
</>
);
}
+8
View File
@@ -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 <ForgotPasswordForm />;
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Mic } from "lucide-react";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-secondary px-4 py-12">
<div className="pointer-events-none absolute inset-0 bg-hero-wash" />
<Link
href="/"
className="relative mb-8 flex items-center gap-2.5 font-display text-xl font-bold tracking-tight"
>
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
PodcastYes
</Link>
<div className="relative w-full max-w-md">{children}</div>
</div>
);
}
+13
View File
@@ -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 (
<Suspense>
<ResetPasswordForm />
</Suspense>
);
}
+18
View File
@@ -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 (
<Suspense>
<SignInForm googleEnabled={googleEnabled} />
</Suspense>
);
}
+13
View File
@@ -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 <SignUpForm googleEnabled={googleEnabled} />;
}
+12
View File
@@ -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 (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
</div>
);
}
+242
View File
@@ -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 */}
<section className="relative overflow-hidden bg-hero-wash">
<div className="container flex flex-col items-center gap-7 py-24 text-center md:py-36">
<Badge variant="secondary" className="py-1.5">
<Sparkles className="h-3.5 w-3.5 text-brand" />
GPT-4 · ElevenLabs · DALL·E in one workflow
</Badge>
<h1 className="max-w-4xl text-balance font-display text-5xl font-extrabold leading-[1.05] tracking-tight sm:text-6xl md:text-7xl">
From a topic idea to a{" "}
<span className="text-brand">finished podcast</span> in minutes
</h1>
<p className="max-w-2xl text-lg text-muted-foreground sm:text-xl">
PodcastYes writes the script, records realistic multi-voice audio, and designs the cover
art automatically. No microphone, no editing, no design skills required.
</p>
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg">
<Link href="/sign-up">
Start free <ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/#how-it-works">See how it works</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Free plan includes 3 scripts &amp; 1 audio generation / month. No card required.
</p>
</div>
</section>
{/* How it works */}
<section id="how-it-works" className="border-t border-border bg-secondary py-24 md:py-28">
<div className="container">
<SectionHeading
eyebrow="How it works"
title="Three steps to a published episode"
subtitle="Configure once. The AI generates everything. You fine-tune and publish."
/>
<div className="mt-16 grid gap-6 md:grid-cols-3">
{STEPS.map((step, i) => (
<Card key={step.title} className="relative transition-shadow hover:shadow-md">
<CardContent className="space-y-4 p-8">
<div className="flex items-center justify-between">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<step.icon className="h-6 w-6" />
</span>
<span className="font-display text-5xl font-extrabold text-foreground/[0.08]">
{String(i + 1).padStart(2, "0")}
</span>
</div>
<h3 className="font-display text-xl font-bold tracking-tight">{step.title}</h3>
<p className="text-muted-foreground">{step.body}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-24 md:py-28">
<div className="container">
<SectionHeading
eyebrow="Everything in one place"
title="The whole podcast toolkit"
subtitle="Replace a writer, a voice actor, an editor, and a designer with one workflow."
/>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((f) => (
<div
key={f.title}
className="flex gap-4 rounded-2xl border border-border bg-card p-6 transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<f.icon className="h-5 w-5" />
</span>
<div className="space-y-1">
<h3 className="font-display font-bold tracking-tight">{f.title}</h3>
<p className="text-sm text-muted-foreground">{f.body}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Pricing preview */}
<section id="pricing" className="border-t border-border bg-secondary py-24 md:py-28">
<div className="container">
<SectionHeading
eyebrow="Pricing"
title="Start free, upgrade as you grow"
subtitle="Simple monthly plans. Cancel anytime."
/>
<div className="mt-16 grid gap-6 lg:grid-cols-4">
{PLAN_ORDER.map((key) => {
const plan = PLANS[key];
return (
<Card
key={key}
className={
plan.highlight
? "relative ring-2 ring-brand shadow-lg"
: "relative"
}
>
{plan.highlight && (
<Badge variant="brand" className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
Most popular
</Badge>
)}
<CardContent className="space-y-6 p-7">
<div>
<h3 className="font-display text-lg font-bold tracking-tight">{plan.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{plan.tagline}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-display text-4xl font-extrabold tracking-tight">{formatPrice(plan.priceMonthly)}</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
<Button
asChild
className="w-full"
variant={plan.highlight ? "default" : "outline"}
>
<Link href="/sign-up">{key === "free" ? "Start free" : `Choose ${plan.name}`}</Link>
</Button>
<ul className="space-y-2.5 text-sm">
{plan.bullets.slice(0, 5).map((b) => (
<li key={b} className="flex gap-2.5">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-brand" />
<span className="text-muted-foreground">{b}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
<p className="mt-10 text-center text-sm text-muted-foreground">
Full comparison on the{" "}
<Link href="/pricing" className="font-semibold text-brand hover:underline">
pricing page
</Link>
.
</p>
</div>
</section>
{/* CTA */}
<section className="py-24 md:py-28">
<div className="container">
<div className="relative overflow-hidden rounded-3xl bg-primary px-8 py-20 text-center text-primary-foreground">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_80%_at_50%_0%,hsl(var(--brand)/0.35),transparent_70%)]" />
<div className="relative">
<h2 className="font-display text-4xl font-extrabold tracking-tight sm:text-5xl">
Make your first episode today
</h2>
<p className="mx-auto mt-4 max-w-xl text-lg text-primary-foreground/75">
Spin up a fully produced episode on the free plan in a couple of minutes then decide.
</p>
<Button asChild size="lg" variant="brand" className="mt-9">
<Link href="/sign-up">
Get started free <ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
</section>
</>
);
}
function SectionHeading({
eyebrow,
title,
subtitle,
}: {
eyebrow: string;
title: string;
subtitle: string;
}) {
return (
<div className="mx-auto max-w-2xl text-center">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">{eyebrow}</p>
<h2 className="mt-3 font-display text-3xl font-extrabold tracking-tight sm:text-4xl md:text-5xl">{title}</h2>
<p className="mt-4 text-lg text-muted-foreground">{subtitle}</p>
</div>
);
}
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." },
];
+76
View File
@@ -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 (
<div className="bg-hero-wash">
<div className="container py-24 md:py-28">
<div className="mx-auto max-w-2xl text-center">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Pricing</p>
<h1 className="mt-3 font-display text-5xl font-extrabold tracking-tight md:text-6xl">
Start free, scale anytime
</h1>
<p className="mt-4 text-lg text-muted-foreground">
Upgrade for higher limits and more features. Cancel anytime. Pay with Stripe
or PayPal.
</p>
</div>
<div className="mt-16 grid gap-6 lg:grid-cols-4">
{PLAN_ORDER.map((key) => {
const plan = PLANS[key];
return (
<Card
key={key}
className={plan.highlight ? "relative ring-2 ring-brand shadow-lg" : "relative"}
>
{plan.highlight && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
Most popular
</Badge>
)}
<CardContent className="flex h-full flex-col gap-6 p-7">
<div>
<h3 className="font-display text-lg font-bold tracking-tight">{plan.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{plan.tagline}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-display text-5xl font-extrabold tracking-tight">{formatPrice(plan.priceMonthly)}</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
<Button asChild className="w-full" variant={plan.highlight ? "default" : "outline"}>
<Link href="/sign-up">{key === "free" ? "Start free" : `Choose ${plan.name}`}</Link>
</Button>
<ul className="space-y-2.5 text-sm">
{plan.bullets.map((b) => (
<li key={b} className="flex gap-2.5">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-brand" />
<span className="text-muted-foreground">{b}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
<p className="mx-auto mt-12 max-w-2xl text-center text-xs text-muted-foreground">
Prices in USD. Annual billing saves roughly two months. Usage limits reset on the first of
each month.
</p>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Privacy Policy" };
export default function PrivacyPage() {
return (
<div className="container max-w-3xl py-20 md:py-24">
<h1 className="font-display text-4xl font-extrabold tracking-tight md:text-5xl">Privacy Policy</h1>
<p className="mt-4 text-muted-foreground">
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.
</p>
<p className="mt-4 text-sm text-muted-foreground">
This is placeholder copy replace with your reviewed privacy policy before launch.
</p>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Terms of Service" };
export default function TermsPage() {
return (
<div className="container max-w-3xl py-20 md:py-24">
<h1 className="font-display text-4xl font-extrabold tracking-tight md:text-5xl">Terms of Service</h1>
<p className="mt-4 text-muted-foreground">
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.
</p>
<p className="mt-4 text-sm text-muted-foreground">
This is placeholder copy replace with your reviewed legal terms before launch.
</p>
</div>
);
}
+65
View File
@@ -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<string, string> = {
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}"` }
: {}),
},
});
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth/auth";
export const { GET, POST } = toNextJsHandler(auth.handler);
+87
View File
@@ -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<typeof setInterval>;
let pingTimer: ReturnType<typeof setInterval>;
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",
},
});
}
+103
View File
@@ -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 });
}
+33
View File
@@ -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<string, string | undefined> = {};
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");
}
+29
View File
@@ -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");
}
+104
View File
@@ -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;
}
}
+46
View File
@@ -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 (
<html lang="en" suppressHydrationWarning>
<body
className={`${madeforText.variable} ${madeforDisplay.variable} font-sans antialiased`}
>
{children}
<Toaster richColors position="top-center" />
</body>
</html>
);
}
+21
View File
@@ -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"
}
+52
View File
@@ -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 (
<nav className="flex flex-col gap-1 p-3">
{NAV.map((item) => {
const active = item.exact ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
active
? "bg-brand/10 font-semibold text-brand"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
);
}
+38
View File
@@ -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 (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-muted" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="currentColor" className="text-muted-foreground" />
<YAxis tick={{ fontSize: 11 }} stroke="currentColor" className="text-muted-foreground" tickFormatter={(v) => `$${v}`} />
<Tooltip
formatter={(v: number) => `$${v.toFixed(2)}`}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{/* Wix-palette data series: Wix Blue + Deep Purple */}
<Bar dataKey="openai" name="OpenAI" stackId="a" fill="#116DFF" radius={[0, 0, 0, 0]} />
<Bar dataKey="elevenlabs" name="ElevenLabs" stackId="a" fill="#3910ED" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
+73
View File
@@ -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 (
<div className="max-w-2xl space-y-6">
<Card>
<CardContent className="py-4">
<form onSubmit={create} className="flex gap-2">
<Input
placeholder="new_flag_key"
value={newKey}
onChange={(e) => setNewKey(e.target.value.replace(/\s+/g, "_"))}
/>
<Button type="submit">
<Plus className="h-4 w-4" /> Add flag
</Button>
</form>
</CardContent>
</Card>
{flags.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No feature flags yet.</p>
) : (
<div className="divide-y rounded-lg border">
{flags.map((f) => (
<div key={f.key} className="flex items-center justify-between p-4">
<code className="text-sm">{f.key}</code>
<Switch checked={f.enabled} onCheckedChange={(v) => toggle(f.key, v)} />
</div>
))}
</div>
)}
</div>
);
}
+29
View File
@@ -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 (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => act("reviewed")}>
Mark reviewed
</Button>
<Button size="sm" variant="destructive" onClick={() => act("removed")}>
Remove
</Button>
</div>
);
}
+105
View File
@@ -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 (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="p-3 font-medium">User</th>
<th className="p-3 font-medium">Plan</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Joined</th>
<th className="p-3" />
</tr>
</thead>
<tbody className="divide-y">
{users.map((u) => (
<tr key={u.id} className="hover:bg-muted/20">
<td className="p-3">
<p className="font-medium">{u.name}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</td>
<td className="p-3 capitalize">{u.plan}</td>
<td className="p-3">
{u.role === "admin" ? <Badge>admin</Badge> : <span className="text-muted-foreground">user</span>}
</td>
<td className="p-3">
{u.banned ? <Badge variant="destructive">banned</Badge> : <Badge variant="success">active</Badge>}
</td>
<td className="p-3 text-muted-foreground">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="p-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{u.role === "admin" ? (
<DropdownMenuItem onSelect={() => run(() => setRoleAction(u.id, "user"), "Role updated")}>
<ShieldOff className="h-4 w-4" /> Revoke admin
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => run(() => setRoleAction(u.id, "admin"), "Role updated")}>
<ShieldCheck className="h-4 w-4" /> Make admin
</DropdownMenuItem>
)}
{u.banned ? (
<DropdownMenuItem onSelect={() => run(() => banUserAction(u.id, false), "User unbanned")}>
<UserCheck className="h-4 w-4" /> Unban
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => run(() => banUserAction(u.id, true), "User banned")}
>
<Ban className="h-4 w-4" /> Ban
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+112
View File
@@ -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<string | null>(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 (
<div className="max-w-2xl space-y-6">
{newKey && (
<Card className="ring-2 ring-brand">
<CardContent className="space-y-2 py-4">
<p className="text-sm font-medium">Your new API key copy it now, it won&apos;t be shown again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded-lg bg-secondary px-2.5 py-1.5 text-xs">{newKey}</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(newKey);
toast.success("Copied");
}}
>
<Copy className="h-4 w-4" /> Copy
</Button>
</div>
</CardContent>
</Card>
)}
<Card>
<CardContent className="py-4">
<form onSubmit={create} className="flex gap-2">
<Input
placeholder="Key name (e.g. Production)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit" disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
Create key
</Button>
</form>
</CardContent>
</Card>
{keys.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No API keys yet.</p>
) : (
<div className="divide-y rounded-lg border">
{keys.map((k) => (
<div key={k.id} className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<p className="font-medium">{k.name}</p>
<p className="text-xs text-muted-foreground">
<code>{k.prefix}</code> · created {new Date(k.createdAt).toLocaleDateString()}
{k.lastUsedAt ? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}` : " · never used"}
</p>
</div>
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => revoke(k.id)}>
<Trash2 className="h-4 w-4" /> Revoke
</Button>
</div>
))}
</div>
)}
</div>
);
}
+39
View File
@@ -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 (
<div className="space-y-3">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio controls src={src} className="w-full" preload="metadata" />
<div className="flex items-center justify-between">
{durationSec ? (
<span className="text-xs text-muted-foreground">Length {formatDuration(durationSec)}</span>
) : (
<span />
)}
<Button asChild variant="outline" size="sm">
<a href={`${src}?download=1`} download>
<Download className="h-4 w-4" /> Download MP3
</a>
</Button>
</div>
</div>
);
}
+213
View File
@@ -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<BillingInterval>("month");
const [busy, setBusy] = useState<string | null>(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 (
<div className="space-y-8">
{subscription && currentPlan !== "free" && (
<Card>
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-semibold capitalize">{PLANS[currentPlan].name} plan</span>
<Badge variant={subscription.status === "active" ? "success" : "warning"}>
{subscription.cancelAtPeriodEnd ? "Cancels at period end" : subscription.status}
</Badge>
<Badge variant="outline" className="capitalize">{subscription.provider}</Badge>
</div>
{subscription.periodEnd && (
<p className="mt-1 text-sm text-muted-foreground">
{subscription.cancelAtPeriodEnd ? "Access until" : "Renews"}{" "}
{new Date(subscription.periodEnd).toLocaleDateString()}
</p>
)}
</div>
<div className="flex gap-2">
{subscription.provider === "stripe" && (
<Button variant="outline" onClick={() => go(openStripePortalAction, "portal")} disabled={busy === "portal"}>
{busy === "portal" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
Manage
</Button>
)}
{!subscription.cancelAtPeriodEnd && (
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm("Cancel your subscription at the end of the period?")) {
go(cancelSubscriptionAction, "cancel");
}
}}
disabled={busy === "cancel"}
>
{busy === "cancel" && <Loader2 className="h-4 w-4 animate-spin" />}
Cancel
</Button>
)}
</div>
</CardContent>
</Card>
)}
<div className="flex items-center justify-center gap-2">
<IntervalToggle interval={interval} onChange={setInterval} />
</div>
<div className="grid gap-4 lg:grid-cols-4">
{PLAN_ORDER.map((key) => {
const plan = PLANS[key];
const isCurrent = key === currentPlan;
const price = interval === "year" ? plan.priceYearly : plan.priceMonthly;
return (
<Card key={key} className={cn("relative", plan.highlight && "ring-2 ring-brand")}>
{plan.highlight && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
Most popular
</Badge>
)}
<CardContent className="space-y-4 p-5">
<div>
<h3 className="font-semibold">{plan.name}</h3>
<p className="mt-1 text-xs text-muted-foreground">{plan.tagline}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-display text-3xl font-extrabold tracking-tight">{formatPrice(price)}</span>
<span className="text-xs text-muted-foreground">/{interval === "year" ? "yr" : "mo"}</span>
</div>
{isCurrent ? (
<Button variant="secondary" className="w-full" disabled>
<Check className="h-4 w-4" /> Current plan
</Button>
) : key === "free" ? (
<Button variant="outline" className="w-full" disabled>
Free
</Button>
) : (
<div className="space-y-2">
{stripeConfigured && (
<Button
className="w-full"
onClick={() => go(() => startStripeCheckoutAction(key, interval), `stripe-${key}`)}
disabled={busy === `stripe-${key}`}
>
{busy === `stripe-${key}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CreditCard className="h-4 w-4" />
)}
Pay with card
</Button>
)}
{paypalConfigured && (
<Button
variant="outline"
className="w-full"
onClick={() => go(() => startPaypalCheckoutAction(key), `paypal-${key}`)}
disabled={busy === `paypal-${key}`}
>
{busy === `paypal-${key}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
PayPal
</Button>
)}
{!stripeConfigured && !paypalConfigured && (
<p className="text-center text-xs text-muted-foreground">Billing not configured</p>
)}
</div>
)}
<ul className="space-y-1.5 pt-2 text-xs">
{plan.bullets.slice(0, 5).map((b) => (
<li key={b} className="flex gap-1.5">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand" />
<span className="text-muted-foreground">{b}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
function IntervalToggle({
interval,
onChange,
}: {
interval: BillingInterval;
onChange: (i: BillingInterval) => void;
}) {
return (
<div className="inline-flex rounded-full border border-border bg-secondary p-1">
{(["month", "year"] as const).map((i) => (
<button
key={i}
onClick={() => onChange(i)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
interval === i ? "bg-background text-foreground shadow-sm" : "text-muted-foreground"
)}
>
{i === "month" ? "Monthly" : "Yearly"}
{i === "year" && <span className="ml-1 text-xs opacity-80">(2 mo free)</span>}
</button>
))}
</div>
);
}
+66
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreVertical className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => regen("art")}>
<ImageIcon className="h-4 w-4" /> Regenerate cover art
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => regen("full")}>
<RefreshCw className="h-4 w-4" /> Regenerate everything
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={del} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4" /> Delete episode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+47
View File
@@ -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 (
<Link href={`/episodes/${episode.id}`}>
<Card className="group overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-square bg-muted">
{episode.coverArtKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/assets/${episode.coverArtKey}`}
alt={episode.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<Mic2 className="h-10 w-10" />
</div>
)}
<div className="absolute left-2 top-2">
<EpisodeStatusBadge status={episode.status} />
</div>
</div>
<div className="p-3">
<p className="truncate font-medium">{episode.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "}
{episode.createdAt.toLocaleDateString()}
</p>
</div>
</Card>
</Link>
);
}
+26
View File
@@ -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<string, { label: string; variant: BadgeProps["variant"]; spin?: boolean }> = {
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 (
<Badge variant={config.variant} className="gap-1.5 whitespace-nowrap">
{config.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{config.label}
</Badge>
);
}
+336
View File
@@ -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<string>(TONES[0]);
const [format, setFormat] = useState<Format>("SOLO");
const [language, setLanguage] = useState("en");
const [length, setLength] = useState(5);
const [audience, setAudience] = useState("");
const [speakers, setSpeakers] = useState<SpeakerState[]>(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<SpeakerState>) {
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 (
<div className="mx-auto max-w-2xl">
<Stepper step={step} />
<Card className="mt-6">
<CardContent className="space-y-6 p-6">
{step === 1 && (
<>
<Field label="What's your episode about?" htmlFor="topic">
<Textarea
id="topic"
rows={4}
placeholder="e.g. The surprising history of coffee and how it shaped global trade…"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</Field>
<Field label="Episode title (optional)" htmlFor="title">
<Input
id="title"
placeholder="Leave blank to auto-generate"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Tone">
<SimpleSelect value={tone} onChange={setTone} options={TONES.map((t) => ({ value: t, label: t }))} />
</Field>
<Field label="Language">
<SimpleSelect
value={language}
onChange={setLanguage}
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
/>
</Field>
<Field label="Length">
<SimpleSelect
value={String(length)}
onChange={(v) => setLength(Number(v))}
options={lengths.map((l) => ({ value: String(l), label: `${l} minutes` }))}
/>
</Field>
<Field label="Audience (optional)" htmlFor="audience">
<Input
id="audience"
placeholder="e.g. busy professionals"
value={audience}
onChange={(e) => setAudience(e.target.value)}
/>
</Field>
</div>
<Field label="Format">
<div className="grid gap-3 sm:grid-cols-3">
{FORMATS.map((f) => (
<button
key={f.value}
type="button"
onClick={() => changeFormat(f.value)}
className={cn(
"rounded-2xl border p-4 text-left transition-colors",
format === f.value
? "border-brand bg-brand/5 ring-1 ring-brand"
: "border-border hover:bg-secondary"
)}
>
<p className="text-sm font-semibold">{f.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
</button>
))}
</div>
</Field>
</>
)}
{step === 2 && (
<>
<div>
<h2 className="text-lg font-semibold">Cast your voices</h2>
<p className="text-sm text-muted-foreground">
Assign a realistic AI voice to each speaker.
</p>
</div>
<div className="space-y-4">
{speakers.map((sp, idx) => (
<div key={sp.speakerKey} className="rounded-2xl border border-border p-4">
<div className="mb-3 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand/10 text-brand">
<User className="h-4 w-4" />
</span>
<span className="text-sm font-medium capitalize">{sp.speakerKey}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Display name">
<Input
value={sp.displayName}
onChange={(e) => updateSpeaker(idx, { displayName: e.target.value })}
/>
</Field>
<Field label="Voice">
<SimpleSelect
value={sp.elevenVoiceId}
onChange={(v) => updateSpeaker(idx, { elevenVoiceId: v })}
options={VOICE_CATALOG.map((v) => ({
value: v.id,
label: `${v.name} · ${v.gender} · ${v.accent}`,
}))}
/>
</Field>
</div>
</div>
))}
</div>
</>
)}
{step === 3 && (
<>
<div>
<h2 className="text-lg font-semibold">Review &amp; generate</h2>
<p className="text-sm text-muted-foreground">
We&apos;ll write the script, record the audio, and design the cover art.
</p>
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<Summary label="Title" value={title || "Auto-generated"} />
<Summary label="Format" value={FORMATS.find((f) => f.value === format)?.label ?? format} />
<Summary label="Tone" value={tone} />
<Summary label="Length" value={`${length} min`} />
<Summary label="Language" value={LANGUAGES.find((l) => l.code === language)?.label ?? language} />
<Summary label="Voices" value={speakers.map((s) => s.displayName).join(", ")} />
</dl>
<div className="flex items-center gap-2 rounded-2xl border border-border bg-secondary p-4 text-sm text-muted-foreground">
<Badge variant="brand">Heads up</Badge>
Generating uses 1 script + 1 audio credit from your monthly plan.
</div>
</>
)}
<div className="flex items-center justify-between pt-2">
<Button
variant="ghost"
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1 || submitting}
>
<ArrowLeft className="h-4 w-4" /> Back
</Button>
{step < 3 ? (
<Button onClick={next}>
Continue <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={submit} disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Generate episode
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function Stepper({ step }: { step: number }) {
const labels = ["Configure", "Voices", "Review"];
return (
<div className="flex items-center justify-center gap-2">
{labels.map((label, i) => {
const n = i + 1;
const active = step === n;
const done = step > n;
return (
<div key={label} className="flex items-center gap-2">
<span
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold transition-colors",
active || done ? "bg-brand text-brand-foreground" : "bg-secondary text-muted-foreground"
)}
>
{n}
</span>
<span className={cn("text-sm", active ? "font-medium" : "text-muted-foreground")}>{label}</span>
{i < labels.length - 1 && <span className="mx-1 h-px w-6 bg-border" />}
</div>
);
})}
</div>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
</div>
);
}
function SimpleSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function Summary({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border p-3">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 font-medium">{value}</dd>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, CheckCircle2, AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { regenerateAction } from "@/app/(app)/episodes/actions";
const STEPS = [
{ key: "SCRIPTING", label: "Writing the script" },
{ key: "SYNTHESIZING", label: "Recording the audio" },
{ key: "STITCHING", label: "Mixing the audio" },
{ key: "ART", label: "Designing the cover art" },
{ key: "SAVING", label: "Finalizing" },
];
const ORDER = ["QUEUED", "SCRIPTING", "SYNTHESIZING", "STITCHING", "ART", "SAVING", "READY"];
export function GenerationProgress({
episodeId,
initialStatus,
initialStage,
initialError,
}: {
episodeId: string;
initialStatus: string;
initialStage?: string | null;
initialError?: string | null;
}) {
const router = useRouter();
const [status, setStatus] = useState(initialStatus);
const [stage, setStage] = useState<string | null | undefined>(initialStage);
const [error, setError] = useState<string | null | undefined>(initialError);
const [retrying, setRetrying] = useState(false);
useEffect(() => {
if (status === "READY" || status === "FAILED") return;
const es = new EventSource(`/api/episodes/${episodeId}/stream`);
es.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === "open") return;
if (data.status) {
setStatus(data.status);
setStage(data.stage);
if (data.error) setError(data.error);
if (data.status === "READY") {
es.close();
router.refresh();
} else if (data.status === "FAILED") {
es.close();
}
}
};
es.onerror = () => es.close();
return () => es.close();
}, [episodeId, status, router]);
async function retry() {
setRetrying(true);
setStatus("QUEUED");
setError(null);
await regenerateAction(episodeId, "full");
router.refresh();
setRetrying(false);
}
if (status === "FAILED") {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<AlertCircle className="h-10 w-10 text-destructive" />
<div>
<p className="font-medium">Generation failed</p>
<p className="max-w-md text-sm text-muted-foreground">
{error || "Something went wrong while producing this episode."}
</p>
</div>
<Button onClick={retry} disabled={retrying}>
{retrying ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
Try again
</Button>
</CardContent>
</Card>
);
}
const currentIdx = ORDER.indexOf(status);
return (
<Card>
<CardContent className="space-y-5 py-8">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand" />
<p className="mt-3 font-medium">{stage || "Generating your episode…"}</p>
<p className="text-sm text-muted-foreground">This usually takes a minute or two.</p>
</div>
<ol className="mx-auto max-w-sm space-y-3">
{STEPS.map((s) => {
const idx = ORDER.indexOf(s.key);
const done = currentIdx > idx;
const active = status === s.key;
return (
<li key={s.key} className="flex items-center gap-3">
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full",
done && "bg-brand text-brand-foreground",
active && "bg-brand/15 text-brand",
!done && !active && "bg-muted text-muted-foreground"
)}
>
{done ? (
<CheckCircle2 className="h-4 w-4" />
) : active ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
<span className={cn("text-sm", active ? "font-medium" : "text-muted-foreground")}>
{s.label}
</span>
</li>
);
})}
</ol>
</CardContent>
</Card>
);
}
+19
View File
@@ -0,0 +1,19 @@
export function PageHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: React.ReactNode;
}) {
return (
<div className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<h1 className="font-display text-3xl font-extrabold tracking-tight">{title}</h1>
{description && <p className="text-muted-foreground">{description}</p>}
</div>
{action}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { FileText, Hash, Mail, Loader2, Copy, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { repurposeAction } from "@/app/(app)/episodes/actions";
type Format = "blog" | "social_thread" | "newsletter";
type Content = { title: string; body: string } | null;
const FORMATS: { key: Format; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: "blog", label: "Blog post", icon: FileText },
{ key: "social_thread", label: "Social thread", icon: Hash },
{ key: "newsletter", label: "Newsletter", icon: Mail },
];
export function RepurposeClient({
episodeId,
initial,
}: {
episodeId: string;
initial: Record<Format, Content>;
}) {
const [content, setContent] = useState<Record<Format, Content>>(initial);
const [busy, setBusy] = useState<Format | null>(null);
async function generate(format: Format) {
setBusy(format);
const res = await repurposeAction(episodeId, format);
setBusy(null);
if (!res.ok || !res.content) {
toast.error(res.error ?? "Could not generate");
return;
}
setContent((prev) => ({ ...prev, [format]: res.content! }));
toast.success("Generated");
}
function copy(text: string) {
navigator.clipboard.writeText(text).then(() => toast.success("Copied to clipboard"));
}
return (
<div className="grid gap-6 lg:grid-cols-3">
{FORMATS.map((f) => {
const c = content[f.key];
return (
<Card key={f.key} className="flex flex-col">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<f.icon className="h-4 w-4 text-brand" /> {f.label}
</CardTitle>
<Button size="sm" variant="outline" onClick={() => generate(f.key)} disabled={busy === f.key}>
{busy === f.key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{c ? "Regenerate" : "Generate"}
</Button>
</CardHeader>
<CardContent className="flex-1">
{c ? (
<div className="space-y-3">
<p className="font-medium">{c.title}</p>
<div className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
{c.body}
</div>
<Button size="sm" variant="ghost" onClick={() => copy(`${c.title}\n\n${c.body}`)}>
<Copy className="h-4 w-4" /> Copy
</Button>
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
Turn this episode into a {f.label.toLowerCase()}.
</p>
)}
</CardContent>
</Card>
);
})}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, Save, RefreshCw, AudioLines } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
updateScriptAction,
regenerateAction,
regenerateSectionAction,
} from "@/app/(app)/episodes/actions";
interface Turn {
speakerKey: string;
text: string;
}
interface Section {
id: string;
title: string;
turns: Turn[];
}
interface Script {
title: string;
sections: Section[];
}
export function ScriptEditor({
episodeId,
script,
speakerNames,
}: {
episodeId: string;
script: Script;
speakerNames: Record<string, string>;
}) {
const router = useRouter();
const [sections, setSections] = useState<Section[]>(script.sections);
const [dirty, setDirty] = useState(false);
const [saving, startSave] = useTransition();
const [busySection, setBusySection] = useState<string | null>(null);
const [rerecording, setRerecording] = useState(false);
function updateTurn(si: number, ti: number, text: string) {
setSections((prev) =>
prev.map((s, i) =>
i === si ? { ...s, turns: s.turns.map((t, j) => (j === ti ? { ...t, text } : t)) } : s
)
);
setDirty(true);
}
function save() {
startSave(async () => {
const res = await updateScriptAction(episodeId, { title: script.title, sections });
if (res.ok) {
toast.success("Script saved");
setDirty(false);
} else {
toast.error(res.error ?? "Could not save");
}
});
}
async function regenSection(id: string) {
setBusySection(id);
const res = await regenerateSectionAction(episodeId, id);
setBusySection(null);
if (!res.ok || !res.section) {
toast.error(res.error ?? "Could not regenerate");
return;
}
setSections((prev) => prev.map((s) => (s.id === id ? res.section! : s)));
setDirty(false);
toast.success("Section regenerated");
}
async function rerecord() {
setRerecording(true);
if (dirty) {
const saved = await updateScriptAction(episodeId, { title: script.title, sections });
if (!saved.ok) {
toast.error(saved.error ?? "Save failed");
setRerecording(false);
return;
}
setDirty(false);
}
const res = await regenerateAction(episodeId, "audio");
if (res.ok) {
toast.success("Re-recording audio…");
router.refresh();
} else {
toast.error(res.error ?? "Could not re-record");
setRerecording(false);
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Script</h2>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={rerecord} disabled={rerecording}>
{rerecording ? <Loader2 className="h-4 w-4 animate-spin" /> : <AudioLines className="h-4 w-4" />}
Re-record audio
</Button>
<Button size="sm" onClick={save} disabled={!dirty || saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
</div>
</div>
{sections.map((section, si) => (
<Card key={section.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-sm">{section.title}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => regenSection(section.id)}
disabled={busySection === section.id}
>
{busySection === section.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Regenerate
</Button>
</CardHeader>
<CardContent className="space-y-3">
{section.turns.map((turn, ti) => (
<div key={ti} className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">
{speakerNames[turn.speakerKey] ?? turn.speakerKey}
</span>
<Textarea
value={turn.text}
onChange={(e) => updateTurn(si, ti, e.target.value)}
rows={Math.max(2, Math.ceil(turn.text.length / 80))}
/>
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Sparkles } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TONES, LANGUAGES } from "@/lib/episodes/options";
import { createSeriesAction } from "@/app/(app)/series/actions";
export function SeriesCreateForm() {
const router = useRouter();
const [theme, setTheme] = useState("");
const [count, setCount] = useState("6");
const [tone, setTone] = useState<string>(TONES[0]);
const [audience, setAudience] = useState("");
const [language, setLanguage] = useState("en");
const [submitting, setSubmitting] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (theme.trim().length < 5) {
toast.error("Describe your season theme.");
return;
}
setSubmitting(true);
const res = await createSeriesAction({
theme: theme.trim(),
count: Number(count),
tone,
audience: audience.trim() || undefined,
language,
});
if (!res.ok || !res.seriesId) {
toast.error(res.error ?? "Could not plan season");
setSubmitting(false);
return;
}
toast.success("Season planned!");
router.push(`/series/${res.seriesId}`);
}
return (
<Card className="max-w-2xl">
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="theme">Season theme</Label>
<Textarea
id="theme"
rows={3}
placeholder="e.g. A beginner's journey through personal finance"
value={theme}
onChange={(e) => setTheme(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Episodes</Label>
<Select value={count} onValueChange={setCount}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[2, 3, 4, 5, 6, 8, 10, 12].map((n) => (
<SelectItem key={n} value={String(n)}>
{n} episodes
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tone</Label>
<Select value={tone} onValueChange={setTone}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TONES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="aud">Audience (optional)</Label>
<Input id="aud" value={audience} onChange={(e) => setAudience(e.target.value)} />
</div>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Plan season
</Button>
</form>
</CardContent>
</Card>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { generateFromSeriesAction } from "@/app/(app)/series/actions";
interface PlanItem {
title: string;
topic: string;
summary: string;
}
export function SeriesDetailClient({
seriesId,
episodes,
}: {
seriesId: string;
episodes: PlanItem[];
}) {
const router = useRouter();
const [busy, setBusy] = useState<number | null>(null);
async function generate(index: number) {
setBusy(index);
const res = await generateFromSeriesAction(seriesId, index);
setBusy(null);
if (!res.ok || !res.episodeId) {
toast.error(res.error ?? "Could not generate");
return;
}
toast.success("Generating episode…");
router.push(`/episodes/${res.episodeId}`);
}
return (
<div className="space-y-3">
{episodes.map((ep, i) => (
<Card key={i}>
<CardContent className="flex items-start justify-between gap-4 py-4">
<div className="min-w-0">
<p className="font-medium">
{i + 1}. {ep.title}
</p>
<p className="text-sm text-muted-foreground">{ep.summary}</p>
</div>
<Button size="sm" onClick={() => generate(i)} disabled={busy === i}>
{busy === i ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Generate
</Button>
</CardContent>
</Card>
))}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { authClient } from "@/lib/auth/auth-client";
export function SettingsClient({ name, email }: { name: string; email: string }) {
const router = useRouter();
const [displayName, setDisplayName] = useState(name);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPw, setSavingPw] = useState(false);
async function saveProfile(e: React.FormEvent) {
e.preventDefault();
setSavingProfile(true);
const { error } = await authClient.updateUser({ name: displayName });
setSavingProfile(false);
if (error) toast.error(error.message ?? "Could not update");
else {
toast.success("Profile updated");
router.refresh();
}
}
async function changePassword(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
setSavingPw(true);
const { error } = await authClient.changePassword({
currentPassword: String(form.get("current")),
newPassword: String(form.get("new")),
revokeOtherSessions: true,
});
setSavingPw(false);
if (error) toast.error(error.message ?? "Could not change password");
else {
toast.success("Password changed");
e.currentTarget.reset();
}
}
return (
<div className="max-w-xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Update your name and see your email.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={saveProfile} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={email} disabled />
</div>
<Button type="submit" disabled={savingProfile}>
{savingProfile && <Loader2 className="h-4 w-4 animate-spin" />}
Save profile
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>Change your account password.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={changePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">Current password</Label>
<Input id="current" name="current" type="password" required />
</div>
<div className="space-y-2">
<Label htmlFor="new">New password</Label>
<Input id="new" name="new" type="password" minLength={8} required />
</div>
<Button type="submit" disabled={savingPw}>
{savingPw && <Loader2 className="h-4 w-4 animate-spin" />}
Change password
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Mic2,
ListMusic,
BarChart3,
CreditCard,
Users,
KeyRound,
Settings,
Lock,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import type { PlanKey, FeatureKey } from "@/lib/billing/plans";
import { PLANS } from "@/lib/billing/plans";
interface NavItem {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
requiresFeature?: FeatureKey;
}
const NAV: NavItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Episodes", href: "/episodes", icon: Mic2 },
{ label: "Series", href: "/series", icon: ListMusic, requiresFeature: "series_generator" },
{ label: "Usage", href: "/usage", icon: BarChart3 },
{ label: "Billing", href: "/billing", icon: CreditCard },
{ label: "Team", href: "/team", icon: Users, requiresFeature: "team_workspace" },
{ label: "API Keys", href: "/api-keys", icon: KeyRound, requiresFeature: "api_access" },
{ label: "Settings", href: "/settings", icon: Settings },
];
export function SidebarNav({ plan }: { plan: PlanKey }) {
const pathname = usePathname();
const features = PLANS[plan].features;
return (
<nav className="flex flex-col gap-1 p-4">
{NAV.map((item) => {
const active = pathname === item.href || pathname.startsWith(item.href + "/");
const locked = item.requiresFeature && !features.includes(item.requiresFeature);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
active
? "bg-brand/10 font-semibold text-brand"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
>
<item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
{locked && <Lock className="h-3.5 w-3.5 opacity-60" />}
</Link>
);
})}
<div className="mt-3 px-2">
<Badge variant="brand" className="capitalize">
{plan} plan
</Badge>
</div>
</nav>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, UserPlus, Building2, Save } 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 { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { authClient } from "@/lib/auth/auth-client";
import { saveBrandingAction } from "@/app/(app)/team/actions";
interface Member {
id: string;
name: string;
email: string;
role: string;
}
interface Branding {
brandName: string | null;
primaryColor: string | null;
logoUrl: string | null;
removePoweredBy: boolean;
}
export function TeamClient({
org,
members,
branding,
seats,
}: {
org: { id: string; name: string } | null;
members: Member[];
branding: Branding | null;
seats: number;
}) {
const router = useRouter();
if (!org) return <CreateWorkspace />;
return (
<div className="space-y-6">
<MembersCard orgId={org.id} members={members} seats={seats} />
<BrandingCard orgId={org.id} branding={branding} />
</div>
);
}
function CreateWorkspace() {
const router = useRouter();
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
async function create(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const { data, error } = await authClient.organization.create({ name: name.trim(), slug });
if (error) {
toast.error(error.message ?? "Could not create workspace");
setBusy(false);
return;
}
if (data?.id) await authClient.organization.setActive({ organizationId: data.id });
toast.success("Workspace created");
router.refresh();
}
return (
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" /> Create your team workspace
</CardTitle>
<CardDescription>Invite up to your plan&apos;s seat limit and share a workspace.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={create} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wsname">Workspace name</Label>
<Input id="wsname" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<Button type="submit" disabled={busy || !name.trim()}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Create workspace
</Button>
</form>
</CardContent>
</Card>
);
}
function MembersCard({ orgId, members, seats }: { orgId: string; members: Member[]; seats: number }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
async function invite(e: React.FormEvent) {
e.preventDefault();
if (members.length >= seats) {
toast.error(`Your plan includes ${seats} seats.`);
return;
}
setBusy(true);
await authClient.organization.setActive({ organizationId: orgId });
const { error } = await authClient.organization.inviteMember({ email: email.trim(), role: "member" });
setBusy(false);
if (error) {
toast.error(error.message ?? "Could not invite");
return;
}
toast.success(`Invitation sent to ${email}`);
setEmail("");
router.refresh();
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Members</span>
<Badge variant="secondary">
{members.length} / {seats} seats
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="divide-y rounded-lg border">
{members.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{m.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{m.name}</p>
<p className="truncate text-xs text-muted-foreground">{m.email}</p>
</div>
<Badge variant="outline" className="capitalize">{m.role}</Badge>
</div>
))}
</div>
<form onSubmit={invite} className="flex gap-2">
<Input
type="email"
placeholder="teammate@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Button type="submit" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
Invite
</Button>
</form>
</CardContent>
</Card>
);
}
function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding | null }) {
const router = useRouter();
const [brandName, setBrandName] = useState(branding?.brandName ?? "");
const [primaryColor, setPrimaryColor] = useState(branding?.primaryColor ?? "");
const [logoUrl, setLogoUrl] = useState(branding?.logoUrl ?? "");
const [removePoweredBy, setRemovePoweredBy] = useState(branding?.removePoweredBy ?? false);
const [busy, setBusy] = useState(false);
async function save(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await saveBrandingAction(orgId, { brandName, primaryColor, logoUrl, removePoweredBy });
setBusy(false);
if (res.ok) {
toast.success("Branding saved");
router.refresh();
} else {
toast.error(res.error ?? "Could not save");
}
}
return (
<Card>
<CardHeader>
<CardTitle>White-label branding</CardTitle>
<CardDescription>Make the workspace your own.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={save} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="brand">Brand name</Label>
<Input id="brand" value={brandName} onChange={(e) => setBrandName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="color">Primary colour (hex)</Label>
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="logo">Logo URL</Label>
<Input id="logo" placeholder="https://…" value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} />
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Remove &quot;Powered by PodcastYes&quot;</p>
<p className="text-xs text-muted-foreground">Hide PodcastYes branding for your clients.</p>
</div>
<Switch checked={removePoweredBy} onCheckedChange={setRemovePoweredBy} />
</div>
<Button type="submit" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save branding
</Button>
</form>
</CardContent>
</Card>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
import { Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export function UpgradeGate({
title,
description,
requiredPlan,
}: {
title: string;
description: string;
requiredPlan: string;
}) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<Lock className="h-6 w-6" />
</span>
<div>
<p className="font-medium">{title}</p>
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
</div>
<Button asChild>
<Link href="/billing">Upgrade to {requiredPlan}</Link>
</Button>
</CardContent>
</Card>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LogOut, Settings, Shield } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth/auth-client";
interface UserMenuProps {
name: string;
email: string;
image?: string | null;
isAdmin?: boolean;
}
export function UserMenu({ name, email, image, isAdmin }: UserMenuProps) {
const router = useRouter();
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
async function handleSignOut() {
await signOut();
router.push("/");
router.refresh();
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="outline-none">
<Avatar>
{image && <AvatarImage src={image} alt={name} />}
<AvatarFallback>{initials || "U"}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-xs font-normal text-muted-foreground">{email}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings">
<Settings className="h-4 w-4" /> Settings
</Link>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem asChild>
<Link href="/admin">
<Shield className="h-4 w-4" /> Admin
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleSignOut} className="text-destructive focus:text-destructive">
<LogOut className="h-4 w-4" /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, MailCheck } 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { authClient } from "@/lib/auth/auth-client";
export function ForgotPasswordForm() {
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const form = new FormData(e.currentTarget);
const { error } = await authClient.requestPasswordReset({
email: String(form.get("email")),
redirectTo: "/reset-password",
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Something went wrong");
return;
}
setSent(true);
}
if (sent) {
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-2xl">
<MailCheck className="h-6 w-6 text-brand" /> Check your inbox
</CardTitle>
<CardDescription>
If an account exists for that email, we&apos;ve sent a reset link.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline" className="w-full">
<Link href="/sign-in">Back to sign in</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Forgot your password?</CardTitle>
<CardDescription>Enter your email and we&apos;ll send you a reset link.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Send reset link
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href="/sign-in" className="font-semibold text-brand hover:underline">
Back to sign in
</Link>
</p>
</form>
</CardContent>
</Card>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { signIn } from "@/lib/auth/auth-client";
export function GoogleButton({ callbackURL = "/dashboard" }: { callbackURL?: string }) {
const [loading, setLoading] = useState(false);
async function handleGoogle() {
setLoading(true);
const { error } = await signIn.social({ provider: "google", callbackURL });
if (error) {
toast.error(error.message ?? "Google sign-in failed");
setLoading(false);
}
}
return (
<Button type="button" variant="outline" className="w-full" onClick={handleGoogle} disabled={loading}>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1Z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="currentColor"
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84Z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84C6.71 7.3 9.14 5.38 12 5.38Z"
/>
</svg>
Continue with Google
</Button>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { authClient } from "@/lib/auth/auth-client";
export function ResetPasswordForm() {
const router = useRouter();
const params = useSearchParams();
const token = params.get("token") ?? "";
const errorParam = params.get("error");
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const newPassword = String(form.get("password"));
const confirm = String(form.get("confirm"));
if (newPassword !== confirm) {
toast.error("Passwords don't match");
return;
}
setLoading(true);
const { error } = await authClient.resetPassword({ newPassword, token });
setLoading(false);
if (error) {
toast.error(error.message ?? "Could not reset password");
return;
}
toast.success("Password updated. Please sign in.");
router.push("/sign-in");
}
if (errorParam || !token) {
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Invalid or expired link</CardTitle>
<CardDescription>Please request a new password reset link.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/forgot-password">Request new link</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Set a new password</CardTitle>
<CardDescription>Choose a strong password you don&apos;t use elsewhere.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<Input id="password" name="password" type="password" minLength={8} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm password</Label>
<Input id="confirm" name="confirm" type="password" minLength={8} required />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Update password
</Button>
</form>
</CardContent>
</Card>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { signIn } from "@/lib/auth/auth-client";
import { GoogleButton } from "./google-button";
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
const router = useRouter();
const params = useSearchParams();
const redirectTo = params.get("redirect") || "/dashboard";
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const form = new FormData(e.currentTarget);
const { error } = await signIn.email({
email: String(form.get("email")),
password: String(form.get("password")),
});
if (error) {
toast.error(error.message ?? "Invalid email or password");
setLoading(false);
return;
}
router.push(redirectTo);
router.refresh();
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Sign in to your PodcastYes account.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{googleEnabled && (
<>
<GoogleButton callbackURL={redirectTo} />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
</>
)}
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/forgot-password" className="text-xs text-muted-foreground hover:text-foreground">
Forgot?
</Link>
</div>
<Input id="password" name="password" type="password" autoComplete="current-password" required />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="font-semibold text-brand hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Loader2 } 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { signUp } from "@/lib/auth/auth-client";
import { GoogleButton } from "./google-button";
export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const form = new FormData(e.currentTarget);
const { error } = await signUp.email({
name: String(form.get("name")),
email: String(form.get("email")),
password: String(form.get("password")),
});
if (error) {
toast.error(error.message ?? "Could not create account");
setLoading(false);
return;
}
toast.success("Account created! Welcome to PodcastYes.");
router.push("/dashboard");
router.refresh();
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Create your account</CardTitle>
<CardDescription>Start producing podcasts with AI free, no card required.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{googleEnabled && (
<>
<GoogleButton />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
</>
)}
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" autoComplete="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">At least 8 characters.</p>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Create account
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/sign-in" className="font-semibold text-brand hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { Mic } from "lucide-react";
export function SiteFooter() {
return (
<footer className="border-t border-border bg-secondary">
<div className="container flex flex-col gap-10 py-16 md:flex-row md:justify-between">
<div className="max-w-xs space-y-4">
<Link href="/" className="flex items-center gap-2.5 font-display text-lg font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
PodcastYes
</Link>
<p className="text-sm leading-relaxed text-muted-foreground">
From topic idea to a finished, published podcast episode in minutes script, voice, and
cover art generated by AI.
</p>
</div>
<div className="grid grid-cols-2 gap-10 text-sm sm:grid-cols-3">
<FooterCol
title="Product"
links={[
["How it works", "/#how-it-works"],
["Features", "/#features"],
["Pricing", "/pricing"],
]}
/>
<FooterCol
title="Account"
links={[
["Log in", "/sign-in"],
["Create account", "/sign-up"],
["Dashboard", "/dashboard"],
]}
/>
<FooterCol
title="Legal"
links={[
["Terms", "/terms"],
["Privacy", "/privacy"],
]}
/>
</div>
</div>
<div className="border-t border-border">
<div className="container py-6 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} PodcastYes. All rights reserved.
</div>
</div>
</footer>
);
}
function FooterCol({ title, links }: { title: string; links: [string, string][] }) {
return (
<div className="space-y-3.5">
<p className="font-semibold text-foreground">{title}</p>
<ul className="space-y-2.5 text-muted-foreground">
{links.map(([label, href]) => (
<li key={href}>
<Link href={href} className="transition-colors hover:text-brand">
{label}
</Link>
</li>
))}
</ul>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { Mic } from "lucide-react";
import { Button } from "@/components/ui/button";
export function SiteHeader() {
return (
<header className="sticky top-0 z-40 w-full border-b border-border/70 bg-background/85 backdrop-blur-md">
<div className="container flex h-[72px] items-center justify-between">
<Link href="/" className="flex items-center gap-2.5 font-display text-lg font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
<span>PodcastYes</span>
</Link>
<nav className="hidden items-center gap-8 text-sm font-medium text-muted-foreground md:flex">
<Link href="/#how-it-works" className="transition-colors hover:text-foreground">How it works</Link>
<Link href="/#features" className="transition-colors hover:text-foreground">Features</Link>
<Link href="/pricing" className="transition-colors hover:text-foreground">Pricing</Link>
</nav>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm" className="hidden sm:inline-flex">
<Link href="/sign-in">Log in</Link>
</Button>
<Button asChild size="sm">
<Link href="/sign-up">Get started</Link>
</Button>
</div>
</div>
</header>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
brand: "border-transparent bg-brand/10 text-brand",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive/12 text-destructive",
outline: "border-border text-foreground",
success: "border-transparent bg-success/12 text-success",
warning: "border-transparent bg-warning/15 text-warning",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
// Wix primary action = solid black pill
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/85",
// Wix Blue accent action
brand: "bg-brand text-brand-foreground shadow-sm hover:bg-brand-hover",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border-[1.5px] border-foreground/15 bg-background text-foreground hover:border-foreground/30 hover:bg-secondary",
secondary: "bg-secondary text-secondary-foreground hover:bg-foreground/[0.07]",
ghost: "text-foreground hover:bg-secondary",
link: "text-brand underline-offset-4 hover:underline rounded-none",
},
size: {
default: "h-11 px-5 text-sm",
sm: "h-9 px-4 text-sm",
lg: "h-12 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-2xl border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-display text-lg font-bold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+83
View File
@@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[9rem] overflow-hidden rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuRadioGroup,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-xl border border-input bg-background px-4 py-2 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-semibold leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & { indicatorClassName?: string }
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-brand transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+89
View File
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-11 w-full items-center justify-between whitespace-nowrap rounded-xl border border-input bg-background px-4 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:border-transparent focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
position === "popper" && "data-[side=bottom]:translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pl-8 pr-2 text-sm outline-none focus:bg-brand/10 focus:text-brand data-[state=checked]:font-semibold data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[96px] w-full rounded-xl border border-input bg-background px-4 py-3 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
+107
View File
@@ -0,0 +1,107 @@
# Deploying PodcastYes on Plesk (Linux)
Single-VPS deployment: one Next.js web process + one worker process under PM2, an
**external** Postgres, **local-disk** storage, and **no Redis**.
## 0. Prerequisites on the VPS
```bash
# Node 20+ (Plesk → Tools & Settings → Node.js, or nvm)
node -v # >= 20
# ffmpeg (the worker shells out to it for audio stitching)
sudo apt update && sudo apt install -y ffmpeg
ffmpeg -version
# PM2 (run outside Plesk's Passenger/Node extension)
sudo npm install -g pm2
```
## 1. Database
Create the database on your external Postgres host and put its URL in `.env` as
`DATABASE_URL`. pg-boss auto-creates its own `pgboss` schema on first run.
## 2. Code + env
```bash
cd /var/www/vhosts/<your-domain> # your vhost docroot
git clone <repo> app && cd app # or Plesk Git deployment
cp .env.example .env # then fill in real values
npm ci
```
Fill in `.env`: `DATABASE_URL`, `BETTER_AUTH_SECRET` (`openssl rand -base64 32`),
`BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` (your https domain), `OPENAI_API_KEY`,
`ELEVENLABS_API_KEY`, `STORAGE_DIR` (absolute, e.g.
`/var/www/vhosts/<your-domain>/storage`), Stripe + PayPal keys, `RESEND_API_KEY`.
## 3. Migrate, seed, build
```bash
npx prisma migrate deploy # create all tables
npm run db:seed # populate the Plan catalog
npm run build # next build (standalone) + postbuild asset copy
mkdir -p "$STORAGE_DIR"/{mp3,art,exports}
```
## 4. Run under PM2
```bash
pm2 start ecosystem.config.js # starts podcastyes-web + podcastyes-worker
pm2 save # remember the process list
pm2 startup # print the systemd command to run once (resurrect on reboot)
pm2 logs # tail logs
```
The web process listens on `127.0.0.1:3000`; the worker consumes the pg-boss queue.
## 5. nginx (reverse proxy + public art)
Plesk → Domains → your domain → **Apache & nginx Settings** → *Additional nginx
directives*. Paste `deploy/nginx-podcastyes.conf` (replace `PODCASTYES_DOMAIN` and
the storage path). `proxy_buffering off` is required for the live progress stream (SSE).
## 6. SSL
Plesk → SSL/TLS Certificates → install **Let's Encrypt** for the domain. Make sure
`BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` use `https://`.
## 7. Webhooks
- **Stripe**: Dashboard → Developers → Webhooks → add endpoint
`https://<domain>/api/webhooks/stripe`, events `checkout.session.completed`,
`customer.subscription.created/updated/deleted`. Put the signing secret in
`STRIPE_WEBHOOK_SECRET`. Create the 3 paid Prices and set the `STRIPE_PRICE_*` env vars.
- **PayPal**: create a subscription Product + Plans (Creator/Pro/Agency), set
`PAYPAL_PLAN_*`. Add a webhook to `https://<domain>/api/webhooks/paypal` for the
`BILLING.SUBSCRIPTION.*` events and set `PAYPAL_WEBHOOK_ID`.
## 8. First admin
```bash
# After signing up in the app once:
npm run make-admin you@email.com
```
Then visit `/admin`.
## 9. Redeploys (zero-downtime)
```bash
git pull
npm ci
npx prisma migrate deploy
npm run build
pm2 reload ecosystem.config.js
```
## Backups
`STORAGE_DIR` (generated MP3s + art) is the only state not in Postgres — add it to a
nightly offsite backup. The DB is your external Postgres provider's responsibility.
## Scaling later (optional)
- Move the worker to a second VPS pointing at the same `DATABASE_URL` — change nothing in code.
- Swap local disk for S3/R2 by adding `lib/storage/s3.ts` and switching the registry in `lib/storage/index.ts`.
+31
View File
@@ -0,0 +1,31 @@
# Plesk → Domains → <your-domain> → Apache & nginx Settings →
# "Additional nginx directives". Paste the blocks below (adjust the storage path).
#
# Plesk already terminates SSL (Let's Encrypt) and proxies to Apache; these
# directives make nginx proxy directly to the Next.js standalone server instead,
# and serve public cover art straight from disk.
# Public cover art only (MP3s stay private, served via the authed /api/assets route).
location /media/art/ {
alias /var/www/vhosts/PODCASTYES_DOMAIN/storage/art/;
expires 7d;
add_header Cache-Control "public";
access_log off;
}
# Proxy everything else to the Next.js web process (PM2 podcastyes-web on :3000).
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Critical for Server-Sent Events (episode generation progress stream):
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
+40
View File
@@ -0,0 +1,40 @@
// PM2 process definitions for the Plesk Linux VPS.
// pm2 start ecosystem.config.js # start both
// pm2 reload ecosystem.config.js # zero-downtime redeploy
// pm2 save && pm2 startup # resurrect both on reboot
//
// Run PM2 independently of Plesk's Node/Passenger extension. nginx (managed by Plesk)
// reverse-proxies the domain to the `web` process on PORT.
module.exports = {
apps: [
{
name: "podcastyes-web",
// Next.js standalone server emitted by `next build` (output: "standalone").
script: ".next/standalone/server.js",
cwd: __dirname,
instances: 1,
exec_mode: "fork",
// Load runtime secrets from .env (Node 20+). Keeps secrets out of this file.
node_args: "--env-file=.env",
env: {
NODE_ENV: "production",
PORT: 3000,
HOSTNAME: "127.0.0.1",
},
max_memory_restart: "512M",
},
{
name: "podcastyes-worker",
// Long-running pg-boss consumer; shells out to ffmpeg for audio stitching.
script: "node_modules/.bin/tsx",
args: "worker/index.ts",
cwd: __dirname,
instances: 1,
exec_mode: "fork",
env: {
NODE_ENV: "production",
},
max_memory_restart: "768M",
},
],
};
+52
View File
@@ -0,0 +1,52 @@
import { prisma } from "@/lib/db";
import type { TokenUsage } from "./types";
// Rough 2026 unit prices (USD). Tune in one place; admin cost dashboards read AiCostLog.
const PRICE = {
gptInputPer1k: 0.0025,
gptOutputPer1k: 0.01,
elevenPer1kChars: 0.3,
dallePerImage: 0.04,
};
export function scriptCostUsd(usage: TokenUsage): number {
return round4(
(usage.inputTokens / 1000) * PRICE.gptInputPer1k +
(usage.outputTokens / 1000) * PRICE.gptOutputPer1k
);
}
export function audioCostUsd(characters: number): number {
return round4((characters / 1000) * PRICE.elevenPer1kChars);
}
export function artCostUsd(images: number): number {
return round4(images * PRICE.dallePerImage);
}
export interface CostEntry {
provider: "openai" | "elevenlabs";
operation: "script" | "audio" | "art" | "repurpose";
units: number;
costUsd: number;
episodeId?: string;
userId?: string;
}
/** Record an AI usage/cost line for the admin monitoring dashboard. */
export async function recordCost(entry: CostEntry): Promise<void> {
await prisma.aiCostLog.create({
data: {
provider: entry.provider,
operation: entry.operation,
units: entry.units,
costUsd: entry.costUsd.toFixed(4),
episodeId: entry.episodeId,
userId: entry.userId,
},
});
}
function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}
+45
View File
@@ -0,0 +1,45 @@
import { spawn } from "node:child_process";
const FFMPEG = process.env.FFMPEG_PATH ?? "ffmpeg";
const FFPROBE = process.env.FFPROBE_PATH ?? "ffprobe";
/** Run ffmpeg with the given args; rejects with the tail of stderr on non-zero exit. */
export function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
proc.stderr.on("data", (d) => {
stderr += d.toString();
});
proc.on("error", (err) =>
reject(new Error(`Failed to spawn ffmpeg (${FFMPEG}): ${err.message}`))
);
proc.on("close", (code) =>
code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-600)}`))
);
});
}
/** Probe an audio file's duration in whole seconds, or null if ffprobe is unavailable. */
export function ffprobeDuration(file: string): Promise<number | null> {
return new Promise((resolve) => {
const proc = spawn(FFPROBE, [
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file,
]);
let out = "";
proc.stdout.on("data", (d) => {
out += d.toString();
});
proc.on("error", () => resolve(null));
proc.on("close", () => {
const n = parseFloat(out.trim());
resolve(Number.isFinite(n) ? Math.round(n) : null);
});
});
}
+16
View File
@@ -0,0 +1,16 @@
import OpenAI from "openai";
let client: OpenAI | null = null;
/** Lazily-constructed OpenAI client (used for GPT-4 scripts and DALL·E art). */
export function openai(): OpenAI {
if (!client) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is not set");
client = new OpenAI({ apiKey });
}
return client;
}
export const SCRIPT_MODEL = process.env.OPENAI_SCRIPT_MODEL ?? "gpt-4o";
export const ART_MODEL = process.env.OPENAI_ART_MODEL ?? "dall-e-3";
+228
View File
@@ -0,0 +1,228 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { setEpisodeStatus } from "@/lib/episodes/status";
import { scriptProvider, audioProvider, artProvider } from "@/lib/ai/providers";
import { buildCoverPrompt } from "@/lib/ai/providers/openai-art";
import { segmentScript } from "./segment";
import { stitchMp3 } from "./stitch";
import { storage, assetKey } from "@/lib/storage";
import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/cost";
import { incrementUsage } from "@/lib/usage/meter";
import { sendEmail, emailLayout } from "@/lib/email";
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
import type { GenerationType } from "@/lib/queue/jobs";
type EpisodeWithRelations = Prisma.EpisodeGetPayload<{
include: { speakers: true; user: true };
}>;
/**
* The episode generation pipeline, run by the worker.
* Stages: script → segment → synthesize → stitch → art → save → meter.
* `type` selects which stages run (full, or a single re-generation).
*/
export async function runEpisodeGeneration(
episodeId: string,
type: GenerationType = "full"
): Promise<void> {
const episode = await loadEpisode(episodeId);
const config = toConfig(episode);
const did = { script: false, audio: false, art: false };
if (type === "full" || type === "script") {
await generateScript(episode, config);
did.script = true;
}
if (type === "full" || type === "script" || type === "audio") {
await generateAudio(episode);
did.audio = true;
}
if (type === "full" || type === "art") {
await generateArt(episode);
did.art = true;
}
await setEpisodeStatus(episodeId, "SAVING", { stage: "Finalizing your episode" });
await meter(episode, did);
await setEpisodeStatus(episodeId, "READY", { stage: "Done" });
await notifyReady(episode);
}
async function loadEpisode(episodeId: string): Promise<EpisodeWithRelations> {
const episode = await prisma.episode.findUnique({
where: { id: episodeId },
include: { speakers: true, user: true },
});
if (!episode) throw new Error(`Episode ${episodeId} not found`);
return episode;
}
function toConfig(episode: EpisodeWithRelations): EpisodeConfig {
const speakers =
episode.speakers.length > 0
? episode.speakers.map((s) => ({ speakerKey: s.speakerKey, displayName: s.displayName }))
: [{ speakerKey: "host", displayName: "Host" }];
return {
title: episode.title,
topic: episode.topic,
tone: episode.tone,
format: episode.format,
language: episode.language,
targetLengthMin: episode.targetLengthMin,
audience: episode.audience ?? undefined,
speakers,
};
}
// ─────────────── Stage 1: script ───────────────
async function generateScript(episode: EpisodeWithRelations, config: EpisodeConfig) {
await setEpisodeStatus(episode.id, "SCRIPTING", { stage: "Writing the script" });
const { script, usage } = await scriptProvider().generate(config);
await prisma.script.upsert({
where: { episodeId: episode.id },
create: {
episodeId: episode.id,
content: script as unknown as Prisma.InputJsonValue,
model: scriptProvider().model,
},
update: { content: script as unknown as Prisma.InputJsonValue, version: { increment: 1 } },
});
// Adopt the generated title when the user didn't set one.
if (!episode.title?.trim() && script.title) {
await prisma.episode.update({ where: { id: episode.id }, data: { title: script.title } });
episode.title = script.title;
}
await recordCost({
provider: "openai",
operation: "script",
units: usage.inputTokens + usage.outputTokens,
costUsd: scriptCostUsd(usage),
episodeId: episode.id,
userId: episode.userId,
});
}
// ─────────────── Stages 24: segment → synthesize → stitch ───────────────
async function generateAudio(episode: EpisodeWithRelations) {
await setEpisodeStatus(episode.id, "SYNTHESIZING", { stage: "Recording the audio" });
const scriptRow = await prisma.script.findUnique({ where: { episodeId: episode.id } });
if (!scriptRow) throw new Error("Cannot synthesize audio before a script exists");
const script = scriptRow.content as unknown as StructuredScript;
const voiceMap: Record<string, string> = {};
for (const s of episode.speakers) voiceMap[s.speakerKey] = s.elevenVoiceId;
const fallback =
episode.speakers[0]?.elevenVoiceId ?? DEFAULT_VOICE_IDS.host;
const provider = audioProvider();
const segments = segmentScript(script, voiceMap, fallback, provider.maxCharsPerRequest);
if (segments.length === 0) throw new Error("Script produced no spoken lines");
const buffers: Buffer[] = [];
let totalChars = 0;
for (const seg of segments) {
const res =
seg.uniqueVoices <= 1
? await provider.synthesizeSpeech(
seg.turns.map((t) => t.text).join(" "),
seg.turns[0].voiceId,
{ language: episode.language }
)
: await provider.synthesizeDialogue(seg.turns, { language: episode.language });
buffers.push(res.audio);
totalChars += res.characters;
}
await setEpisodeStatus(episode.id, "STITCHING", { stage: "Mixing the audio" });
const { data, durationSec } = await stitchMp3(buffers);
const key = assetKey("mp3", `${episode.id}.mp3`);
await storage().put(key, data, "audio/mpeg");
await prisma.audioAsset.upsert({
where: { episodeId: episode.id },
create: {
episodeId: episode.id,
storageKey: key,
durationSec,
sizeBytes: data.length,
segments: { count: segments.length } as Prisma.InputJsonValue,
},
update: {
storageKey: key,
durationSec,
sizeBytes: data.length,
segments: { count: segments.length } as Prisma.InputJsonValue,
},
});
await recordCost({
provider: "elevenlabs",
operation: "audio",
units: totalChars,
costUsd: audioCostUsd(totalChars),
episodeId: episode.id,
userId: episode.userId,
});
}
// ─────────────── Stage 5: cover art ───────────────
async function generateArt(episode: EpisodeWithRelations) {
await setEpisodeStatus(episode.id, "ART", { stage: "Designing the cover art" });
const prompt = buildCoverPrompt(episode.topic, episode.tone, episode.title);
const { data, revisedPrompt } = await artProvider().generateCover(prompt);
const key = assetKey("art", `${episode.id}.png`);
await storage().put(key, data, "image/png");
await prisma.coverArt.upsert({
where: { episodeId: episode.id },
create: { episodeId: episode.id, storageKey: key, prompt: revisedPrompt ?? prompt, model: artProvider().model },
update: { storageKey: key, prompt: revisedPrompt ?? prompt },
});
await recordCost({
provider: "openai",
operation: "art",
units: 1,
costUsd: artCostUsd(1),
episodeId: episode.id,
userId: episode.userId,
});
}
// ─────────────── Stage 7: meter ───────────────
async function meter(
episode: EpisodeWithRelations,
did: { script: boolean; audio: boolean; art: boolean }
) {
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
if (did.script) await incrementUsage(ownerId, ownerType, "script");
if (did.audio) await incrementUsage(ownerId, ownerType, "audio");
if (did.art) await incrementUsage(ownerId, ownerType, "art");
}
async function notifyReady(episode: EpisodeWithRelations) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
try {
await sendEmail({
to: episode.user.email,
subject: `🎙️ "${episode.title}" is ready`,
html: emailLayout(
"Your episode is ready",
`${episode.title}” has finished generating — script, audio, and cover art are all set.`,
{ label: "Open episode", url: `${appUrl}/episodes/${episode.id}` }
),
text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`,
});
} catch (err) {
console.error("[notifyReady] email failed (non-fatal)", err);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from "zod";
import { openai, SCRIPT_MODEL } from "@/lib/ai/openai";
import type { StructuredScript, TokenUsage } from "@/lib/ai/types";
export type RepurposeFormat = "blog" | "social_thread" | "newsletter";
const FORMAT_PROMPTS: Record<RepurposeFormat, string> = {
blog: "Write an engaging, SEO-friendly blog post based on this episode. Include a compelling title and well-structured markdown body with headings and a short conclusion.",
social_thread:
"Write a punchy social thread (610 posts, numbered) summarizing the episode's best insights. Start with a strong hook. Put the whole thread in the markdown body.",
newsletter:
"Write a friendly email newsletter edition about this episode: a subject line as the title, a short intro, 34 key takeaways as bullets, and a call-to-action to listen. Markdown body.",
};
const outputSchema = z.object({ title: z.string().min(1), body: z.string().min(1) });
export type RepurposedOutput = z.infer<typeof outputSchema>;
function scriptToText(script: StructuredScript): string {
return script.sections
.map((s) => `## ${s.title}\n` + s.turns.map((t) => t.text).join("\n"))
.join("\n\n");
}
export async function repurposeScript(
script: StructuredScript,
format: RepurposeFormat
): Promise<{ content: RepurposedOutput; usage: TokenUsage }> {
const transcript = scriptToText(script).slice(0, 9000);
const res = await openai().chat.completions.create({
model: SCRIPT_MODEL,
messages: [
{
role: "system",
content:
"You are a content marketer who repurposes podcast episodes into other formats. Return STRICT JSON: { \"title\": string, \"body\": string } where body is markdown.",
},
{
role: "user",
content: `${FORMAT_PROMPTS[format]}\n\nEpisode title: ${script.title}\n\nTranscript:\n${transcript}`,
},
],
response_format: { type: "json_object" },
temperature: 0.7,
});
const content = outputSchema.parse(JSON.parse(res.choices[0]?.message?.content ?? "{}"));
return {
content,
usage: {
inputTokens: res.usage?.prompt_tokens ?? 0,
outputTokens: res.usage?.completion_tokens ?? 0,
},
};
}

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