Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# PodcastYes — Design System
|
||||||
|
|
||||||
|
> A Wix.com‑inspired visual language. This document is the single source of truth for
|
||||||
|
> the look and feel of the entire platform — marketing, auth, app, and admin. Every
|
||||||
|
> screen must follow it. When a component or page disagrees with this doc, the doc wins.
|
||||||
|
|
||||||
|
Reference: [Wix Design Assets & Guidelines](https://www.wix.com/about/design-assets),
|
||||||
|
[Wix Studio Brand Guidelines](https://www.wix.com/studio/about/brand-guidelines),
|
||||||
|
[About the Wix Design System](https://dev.wix.com/docs/build-apps/develop-your-app/design/about-the-wix-design-system).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design principles (the Wix feel)
|
||||||
|
|
||||||
|
1. **Confident & editorial.** Big, bold, tightly‑tracked headlines. Generous whitespace.
|
||||||
|
Let one idea own each section. Marketing reads like a magazine, not a SaaS dashboard.
|
||||||
|
2. **High contrast, mostly monochrome.** Near‑black on white is the base. Color is used
|
||||||
|
deliberately — almost always the single **Wix Blue** accent — never decoratively.
|
||||||
|
3. **Black is the primary action.** Primary buttons are solid black pills. Blue is the
|
||||||
|
*interactive / accent* color (links, focus rings, active nav, highlights). This is the
|
||||||
|
defining Wix.com marketing combination.
|
||||||
|
4. **Soft, rounded, friendly.** Pill buttons, large card radii, gentle shadows. Nothing
|
||||||
|
sharp; nothing heavy.
|
||||||
|
5. **Calm surfaces.** White and one light‑gray (`#F4F4F4`). Borders are hairline and light.
|
||||||
|
Depth comes from spacing and soft shadow, not from boxes inside boxes.
|
||||||
|
6. **One typeface, many weights.** Wix Madefor everywhere. Hierarchy comes from size and
|
||||||
|
weight, not from mixing families.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Color
|
||||||
|
|
||||||
|
### Core palette
|
||||||
|
|
||||||
|
| Token | Hex | HSL | Use |
|
||||||
|
| ---------------- | ---------- | ------------------ | --- |
|
||||||
|
| **Ink / black** | `#0B0B0B` | `0 0% 5%` | Primary buttons, headlines, default text |
|
||||||
|
| **White** | `#FFFFFF` | `0 0% 100%` | Page background, cards |
|
||||||
|
| **Wix Blue** | `#116DFF` | `217 100% 53%` | Links, focus rings, active states, highlights, brand mark |
|
||||||
|
| **Blue (hover)** | `#0B57E0` | `219 90% 46%` | Blue hover / pressed |
|
||||||
|
| **Surface** | `#F4F4F4` | `0 0% 96%` | Section backgrounds, muted fills, inputs‑on‑white |
|
||||||
|
| **Border** | `#E4E4E4` | `0 0% 89%` | Hairline dividers, card borders, input borders |
|
||||||
|
| **Muted text** | `#6A6A6A` | `0 0% 42%` | Secondary / supporting copy |
|
||||||
|
| **Foreground** | `#111111` | `0 0% 7%` | Body text |
|
||||||
|
|
||||||
|
### Accent markers (use sparingly, for illustration/badges/status only)
|
||||||
|
|
||||||
|
Wix pairs its monochrome base with a few bright markers. Use these only for data viz,
|
||||||
|
status, and small decorative moments — never for primary UI chrome.
|
||||||
|
|
||||||
|
| Name | Hex | Use |
|
||||||
|
| ----------- | ---------- | --- |
|
||||||
|
| Lime | `#DEFF00` | Highlight chip, "new" marker |
|
||||||
|
| Orange | `#FF5500` | Warning / attention |
|
||||||
|
| Green | `#00C271` | Success |
|
||||||
|
| Deep Purple | `#3910ED` | Secondary data series |
|
||||||
|
| Light Blue | `#ADD7FF` | Tints, illustration fills |
|
||||||
|
|
||||||
|
### Semantic mapping (CSS variables, HSL triplets)
|
||||||
|
|
||||||
|
```
|
||||||
|
--background 0 0% 100% /* white page */
|
||||||
|
--foreground 0 0% 7% /* near-black text */
|
||||||
|
--card 0 0% 100%
|
||||||
|
--card-foreground 0 0% 7%
|
||||||
|
--primary 0 0% 5% /* INK — black buttons */
|
||||||
|
--primary-foreground 0 0% 100%
|
||||||
|
--brand 217 100% 53% /* WIX BLUE — links, accents, active */
|
||||||
|
--brand-foreground 0 0% 100%
|
||||||
|
--secondary 0 0% 96% /* surface gray fill */
|
||||||
|
--secondary-foreground 0 0% 7%
|
||||||
|
--muted 0 0% 96%
|
||||||
|
--muted-foreground 0 0% 42%
|
||||||
|
--accent 0 0% 96% /* hover fill */
|
||||||
|
--accent-foreground 0 0% 7%
|
||||||
|
--destructive 4 86% 58%
|
||||||
|
--success 157 100% 38%
|
||||||
|
--warning 24 100% 50%
|
||||||
|
--border 0 0% 89%
|
||||||
|
--input 0 0% 89%
|
||||||
|
--ring 217 100% 53% /* focus ring = Wix Blue */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- Default text = `foreground`. Secondary text = `muted-foreground`. Never lighter than `#6A6A6A` on white.
|
||||||
|
- Links and any "interactive accent" = **Wix Blue** (`text-brand`). Hover → underline or darker blue.
|
||||||
|
- Primary CTA = **black** pill. Never blue and black competing in the same button group.
|
||||||
|
- Focus is always a **2px Wix‑Blue ring** with a 2px offset. No exceptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
**Family:** **Wix Madefor** (via Google Fonts / `next/font`).
|
||||||
|
- `Wix Madefor Display` → headings (`--font-display`).
|
||||||
|
- `Wix Madefor Text` → body & UI (`--font-sans`).
|
||||||
|
|
||||||
|
System fallback stack: `ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif`.
|
||||||
|
|
||||||
|
### Type scale
|
||||||
|
|
||||||
|
| Role | Size (desktop) | Weight | Tracking | Notes |
|
||||||
|
| --------------- | --------------------- | ------ | -------- | ----- |
|
||||||
|
| Display / Hero | `clamp(2.75rem,6vw,4.5rem)` (44–72px) | 800 | `-0.03em` | Display font, `leading-[1.05]` |
|
||||||
|
| H1 (page) | `2.25–3rem` (36–48px) | 700 | `-0.02em` | Display font |
|
||||||
|
| H2 (section) | `1.75–2.25rem` (28–36px)| 700 | `-0.02em` | Display font |
|
||||||
|
| H3 | `1.25rem` (20px) | 600 | `-0.01em` | |
|
||||||
|
| Lead paragraph | `1.125–1.25rem` (18–20px)| 400 | normal | `text-muted-foreground` |
|
||||||
|
| Body | `1rem` (16px) | 400 | normal | base UI = 14–16px |
|
||||||
|
| Small / meta | `0.875rem` (14px) | 500 | normal | |
|
||||||
|
| Eyebrow / label | `0.8125rem` (13px) | 600 | `0.04em` UPPERCASE | Use **Wix Blue** or muted |
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- Headlines are tight: always pair big display sizes with `tracking-tight` and `leading-[1.05–1.1]`.
|
||||||
|
- Body line length caps at ~`max-w-2xl` (≈42rem) for readability.
|
||||||
|
- Don't uppercase anything except small eyebrows/labels.
|
||||||
|
- Numbers in stats/pricing use the display font, weight 800.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Shape, elevation, spacing
|
||||||
|
|
||||||
|
### Radius
|
||||||
|
- **Buttons:** pill — `rounded-full` (Wix CTA buttons ≈ 18px+; pill at our sizes).
|
||||||
|
- **Inputs / selects:** `rounded-xl` (12px).
|
||||||
|
- **Cards / panels:** `rounded-2xl` (16px) default; hero/feature cards `rounded-3xl` (24px).
|
||||||
|
- **Chips / badges:** `rounded-full`.
|
||||||
|
- Base `--radius: 0.875rem`.
|
||||||
|
|
||||||
|
### Elevation (soft, never harsh)
|
||||||
|
```
|
||||||
|
shadow-sm → 0 1px 2px rgba(11,11,11,.05)
|
||||||
|
shadow → 0 2px 8px rgba(11,11,11,.06)
|
||||||
|
shadow-md → 0 8px 24px rgba(11,11,11,.08) /* raised cards, menus */
|
||||||
|
shadow-lg → 0 16px 48px rgba(11,11,11,.12) /* popovers, highlighted pricing */
|
||||||
|
```
|
||||||
|
Cards default to a hairline `border` + `shadow-sm`. Elevate on hover (`hover:shadow-md`)
|
||||||
|
for interactive cards. No hard drop shadows, no inner shadows.
|
||||||
|
|
||||||
|
### Spacing & layout
|
||||||
|
- Container: centered, `max-w-[1280px]`, horizontal padding `1.5rem` (mobile) → `2rem`.
|
||||||
|
- **Section rhythm:** `py-20` mobile → `py-28`/`py-32` desktop. Marketing breathes.
|
||||||
|
- Alternate section backgrounds white ↔ `secondary` (`#F4F4F4`) to segment the page.
|
||||||
|
- Card padding: `p-6` (compact) to `p-8` (marketing).
|
||||||
|
- Grid gaps: `gap-6` default, `gap-8` for marketing feature grids.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Components
|
||||||
|
|
||||||
|
### Buttons (`<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` | Wix‑Blue 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, multi‑column 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`), full‑bleed where possible. Generous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Motion
|
||||||
|
- Transitions `150–200ms 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 + Wix‑Blue 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, boxes‑in‑boxes.
|
||||||
|
- ✅ Pill buttons & chips, `rounded-2xl` cards. ❌ Sharp corners / `rounded-md` chrome.
|
||||||
|
- ✅ Blue focus ring everywhere. ❌ Default browser outline or no focus state.
|
||||||
@@ -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).
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }))} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) + "…";
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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." },
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}"` }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
import { auth } from "@/lib/auth/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & generate</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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 "Powered by PodcastYes"</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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't have an account?{" "}
|
||||||
|
<Link href="/sign-up" className="font-semibold text-brand hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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`.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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 2–4: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (6–10 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, 3–4 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
Reference in New Issue
Block a user