` or GFM tables).
+- **Code:** Need a `code` type (HTML `` or MD code fences).
diff --git a/.agents/skills/sanity-best-practices/references/nextjs.md b/.agents/skills/sanity-best-practices/references/nextjs.md
new file mode 100644
index 000000000..cad3d239f
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/nextjs.md
@@ -0,0 +1,544 @@
+---
+title: Next.js & Sanity Integration Rules
+description: Integration guide for Next.js App Router, Live Content API, and Sanity Studio (Embedded or Standalone).
+---
+
+# Next.js & Sanity Integration Rules
+
+Jump to the section that matches the task instead of reading this guide end-to-end.
+
+## Table of Contents
+
+- Architecture patterns
+- Data fetching (Live Content API)
+- Caching and revalidation
+- Visual Editing and clean data
+- Embedded Studio setup
+- Draft Mode setup
+- Error handling
+- Presentation queries
+- Pagination pattern
+
+## 1. Architecture Patterns
+
+### Option A: Embedded Studio (Recommended)
+**Best for:** Most Next.js projects. Unified deployment, simpler setup.
+
+The Studio lives inside your Next.js app at `/app/studio/[[...tool]]/page.tsx`.
+- **Config:** `sanity.config.ts` lives in the project root.
+- See `project-structure.md` rule for detailed structure.
+
+### Option B: Monorepo (Alternative)
+**Best for:** Separation of concerns, multiple frontends, or strict dependency isolation.
+
+The Studio and Next.js app live in separate folders:
+```
+apps/
+├── studio/ # Sanity Studio (standalone)
+└── web/ # Next.js frontend
+```
+
+- **Config:** Add your Next.js app URL to **CORS Origins** in [Sanity Manage](https://www.sanity.io/manage).
+- See `project-structure.md` rule for detailed structure.
+
+## 2. Data Fetching (Live Content API)
+
+We use `defineLive` (next-sanity v11+) to enable real-time content updates and Visual Editing automatically.
+
+### Setup (`src/sanity/lib/live.ts`)
+
+```typescript
+import { defineLive } from 'next-sanity'
+import { client } from './client'
+
+export const { sanityFetch, SanityLive } = defineLive({
+ client: client.withConfig({
+ apiVersion: '2026-02-01'
+ }),
+ serverToken: process.env.SANITY_API_READ_TOKEN,
+ browserToken: process.env.SANITY_API_READ_TOKEN,
+})
+```
+
+### Rendering (`src/app/layout.tsx`)
+
+You **must** render `` in the root layout to enable real-time updates.
+
+```typescript
+import { SanityLive } from '@/sanity/lib/live'
+import { VisualEditing } from 'next-sanity/visual-editing'
+import { draftMode } from 'next/headers'
+
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ {(await draftMode()).isEnabled && }
+
+
+ )
+}
+```
+
+## 3. Caching & Revalidation
+
+### Prefer Live Content API (Default)
+
+**Use `defineLive` by default.** It handles fetching, caching, and invalidation automatically. Only implement manual caching when you need fine-grained control.
+
+### When to Use Manual Caching
+
+| Scenario | Approach |
+|----------|----------|
+| Real-time updates, Visual Editing | `defineLive` (default) |
+| Static marketing pages, rarely updated | Time-based revalidation |
+| Blog posts, products with frequent edits | Tag-based revalidation |
+| Critical accuracy (stock levels, prices) | Path-based + short revalidation |
+
+### Debugging: Enable Fetch Logging
+
+See every fetch with cache HIT/MISS status:
+
+```typescript
+// next.config.ts
+const nextConfig: NextConfig = {
+ logging: {
+ fetches: {
+ fullUrl: true,
+ },
+ },
+};
+```
+
+Console output shows cache status:
+```text
+GET /posts 200 in 39ms
+ │ GET https://...apicdn.sanity.io/... 200 in 5ms (cache hit)
+```
+
+### Sanity CDN vs API
+
+| Setting | Speed | Freshness | Use When |
+|---------|-------|-----------|----------|
+| `useCdn: true` | Fast | May have brief delay | Default for all runtime fetches |
+| `useCdn: false` | Slower | Guaranteed fresh | `generateStaticParams`, webhooks |
+
+Override per-request:
+```typescript
+// For static generation, use API directly
+export async function generateStaticParams() {
+ const slugs = await client
+ .withConfig({ useCdn: false })
+ .fetch(SLUGS_QUERY);
+ return slugs;
+}
+```
+
+### Manual `sanityFetch` Helper (Advanced)
+
+For manual caching control, create a wrapper:
+
+```typescript
+// src/sanity/lib/client.ts
+export async function sanityFetch({
+ query,
+ params = {},
+ revalidate = 60,
+ tags = [],
+}: {
+ query: QueryString;
+ params?: QueryParams;
+ revalidate?: number | false;
+ tags?: string[];
+}) {
+ return client.fetch(query, params, {
+ next: {
+ revalidate: tags.length ? false : revalidate,
+ tags,
+ },
+ });
+}
+```
+
+### Time-Based Revalidation
+
+Simple and predictable. Good for content that changes infrequently.
+
+```typescript
+const posts = await sanityFetch({
+ query: POSTS_QUERY,
+ revalidate: 3600, // Revalidate every hour
+});
+```
+
+**The "Typo Problem":** With time-based only, content authors may wait up to an hour to see changes. Use webhooks for instant updates.
+
+### Path-Based Revalidation
+
+Surgically revalidate specific routes when documents change.
+
+**1. Create API Route:**
+```typescript
+// src/app/api/revalidate/path/route.ts
+import { revalidatePath } from 'next/cache';
+import { type NextRequest, NextResponse } from 'next/server';
+import { parseBody } from 'next-sanity/webhook';
+
+type WebhookPayload = { path?: string };
+
+export async function POST(req: NextRequest) {
+ try {
+ const { isValidSignature, body } = await parseBody(
+ req,
+ process.env.SANITY_REVALIDATE_SECRET,
+ true // Add delay to allow CDN to update
+ );
+
+ if (!isValidSignature) {
+ return new Response('Invalid signature', { status: 401 });
+ }
+ if (!body?.path) {
+ return new Response('Missing path', { status: 400 });
+ }
+
+ revalidatePath(body.path);
+ return NextResponse.json({ revalidated: body.path });
+ } catch (err) {
+ return new Response((err as Error).message, { status: 500 });
+ }
+}
+```
+
+**2. Create GROQ-Powered Webhook:**
+- URL: `https://yoursite.com/api/revalidate/path`
+- Filter: `_type in ["post"]`
+- Projection: `{ "path": "/posts/" + slug.current }`
+- Add `SANITY_REVALIDATE_SECRET` to webhook and `.env.local`
+
+### Tag-Based Revalidation
+
+"Update once, revalidate everywhere" — best for referenced content.
+
+**1. Tag Your Queries:**
+```typescript
+// Posts index - revalidate when ANY post, author, or category changes
+const posts = await sanityFetch({
+ query: POSTS_QUERY,
+ tags: ['post', 'author', 'category'],
+});
+
+// Individual post - more granular, includes slug-specific tag
+const post = await sanityFetch({
+ query: POST_QUERY,
+ params,
+ tags: [`post:${params.slug}`, 'author', 'category'],
+});
+```
+
+**2. Create API Route:**
+```typescript
+// src/app/api/revalidate/tag/route.ts
+import { revalidateTag } from 'next/cache';
+import { type NextRequest, NextResponse } from 'next/server';
+import { parseBody } from 'next-sanity/webhook';
+
+type WebhookPayload = { tags: string[] };
+
+export async function POST(req: NextRequest) {
+ try {
+ const { isValidSignature, body } = await parseBody(
+ req,
+ process.env.SANITY_REVALIDATE_SECRET,
+ true
+ );
+
+ if (!isValidSignature) {
+ return new Response('Invalid signature', { status: 401 });
+ }
+ if (!Array.isArray(body?.tags) || !body.tags.length) {
+ return new Response('Missing tags', { status: 400 });
+ }
+
+ body.tags.forEach((tag) => revalidateTag(tag));
+ return NextResponse.json({ revalidated: body.tags });
+ } catch (err) {
+ return new Response((err as Error).message, { status: 500 });
+ }
+}
+```
+
+**3. Create GROQ-Powered Webhook:**
+- URL: `https://yoursite.com/api/revalidate/tag`
+- Filter: `_type in ["post", "author", "category"]`
+- Projection: `{ "tags": [_type, _type + ":" + slug.current] }`
+
+### Stale Data After Webhook?
+
+Webhooks fire *before* Sanity CDN updates. If you see stale data:
+
+1. **Add delay** — Pass `true` as third arg to `parseBody`
+2. **Or bypass CDN** — Set `useCdn: false` in client config (use sparingly)
+
+## 4. Visual Editing (Stega) & Clean Data
+
+Visual Editing injects invisible characters into strings to enable click-to-edit.
+
+### A. The Golden Rule of Stega
+
+If a string field controls logic (alignment, colors, IDs), you **must** clean it before comparing.
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export function Layout({ align }: { align: string }) {
+ // ❌ Bad: Will fail in Edit Mode due to invisible chars
+ // if (align === 'center') ...
+
+ // ✅ Good: Clean the value first
+ const cleanAlign = stegaClean(align);
+ return
+}
+```
+
+### B. Metadata & SEO (Critical)
+
+**Never** let Stega characters leak into `` tags. Always set `stega: false` for metadata fetching.
+
+```typescript
+export async function generateMetadata({ params }) {
+ const { data } = await sanityFetch({
+ query: SEO_QUERY,
+ params: await params,
+ stega: false // 👈 Critical for SEO
+ })
+ return { title: data?.title }
+}
+```
+
+### C. Static Params
+
+When generating static params, fetch only published content and disable stega.
+
+```typescript
+export async function generateStaticParams() {
+ const { data } = await sanityFetch({
+ query: SLUGS_QUERY,
+ perspective: 'published', // 👈 No drafts
+ stega: false
+ })
+ return data
+}
+```
+
+## 5. Setup: Embedded Studio
+
+Mount the Studio on a Next.js route.
+
+**`src/app/studio/[[...tool]]/page.tsx`:**
+
+```typescript
+import { NextStudio } from 'next-sanity/studio'
+import config from '../../../../sanity.config'
+
+export const dynamic = 'force-static'
+export { metadata, viewport } from 'next-sanity/studio'
+
+export default function StudioPage() {
+ return
+}
+```
+
+## 6. Setup: Draft Mode
+
+Enable Presentation Tool and Visual Editing by setting up a draft mode route.
+
+**`src/app/api/draft-mode/enable/route.ts`:**
+
+```typescript
+import { client } from '@/sanity/lib/client'
+import { defineEnableDraftMode } from 'next-sanity/draft-mode'
+import { token } from '@/sanity/lib/token' // Helper to get token
+
+export const { GET } = defineEnableDraftMode({
+ client: client.withConfig({ token }),
+})
+```
+
+## 7. Error Handling
+
+Use `notFound()` for missing documents. Common errors:
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| 401 Unauthorized | Invalid/missing token | Check `SANITY_API_READ_TOKEN` |
+| 403 Forbidden | CORS not configured | Add URL to CORS origins |
+| Query syntax error | Invalid GROQ | Test in Vision plugin first |
+| Empty result | Wrong filter/params | Log params, check `_type` spelling |
+
+```typescript
+import { notFound } from 'next/navigation'
+
+export default async function PostPage({ params }: Props) {
+ const { data } = await sanityFetch({ query: POST_QUERY, params: await params })
+ if (!data) notFound()
+ return
+}
+```
+
+## 8. Presentation Queries (`usePresentationQuery`)
+
+For faster live editing in the Presentation Tool, use `usePresentationQuery` to fetch only the specific block being edited, rather than re-rendering the entire page.
+
+### Why Use This
+
+- **Without:** Editing a hero title re-fetches the whole page, re-renders all blocks
+- **With:** Only the hero block re-fetches and re-renders
+
+This is especially valuable for pages with many Page Builder blocks or complex Portable Text.
+
+### Basic Pattern
+
+```typescript
+'use client'
+import { usePresentationQuery } from 'next-sanity/hooks'
+import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
+
+type HeroProps = {
+ _key: string
+ documentId: string
+ title: string
+ subtitle?: string
+ // ... other initial props from page query
+}
+
+export function Hero({ _key, documentId, title, subtitle, ...rest }: HeroProps) {
+ // Fetch block-specific data for faster updates in Presentation Tool
+ const { data } = usePresentationQuery({
+ query: HERO_PRESENTATION_QUERY,
+ params: { documentId, blockKey: _key },
+ })
+
+ // Use presentation data if available, fallback to initial server props
+ const blockData = data?.heroBlock || { title, subtitle, ...rest }
+
+ return (
+
+ {blockData.title}
+ {blockData.subtitle && {blockData.subtitle}
}
+
+ )
+}
+```
+
+### The Presentation Query
+
+Create a query that targets the specific block by `_key`:
+
+```typescript
+// queries.ts
+export const HERO_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ _id,
+ _type,
+ "heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
+ title,
+ subtitle,
+ image,
+ theme,
+ // Include all fields the component needs
+ }
+ }
+`)
+```
+
+### Passing Document Context
+
+Your PageBuilder component needs to pass `documentId` to each block:
+
+```typescript
+export function PageBuilder({ content, documentId }: { content: Block[]; documentId: string }) {
+ return (
+
+ {content.map((block) => {
+ switch (block._type) {
+ case "hero":
+ return
+ // ... other blocks
+ }
+ })}
+
+ )
+}
+```
+
+### For Portable Text Blocks
+
+The same pattern works for custom blocks inside Portable Text:
+
+```typescript
+export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ "pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
+ image,
+ caption,
+ alt
+ }
+ }
+`)
+```
+
+**See also:** `visual-editing.md` for the conceptual overview and `page-builder.md` for full Page Builder patterns.
+
+## 9. Pagination Pattern
+
+For listing pages with many entries, use offset-based pagination with a count query.
+
+### Queries
+```typescript
+// Paginated listing
+export const ARTICLES_QUERY = defineQuery(`
+ *[_type == "article" && defined(slug.current)]
+ | order(date desc) [$start...$end] {
+ _id, title, "slug": slug.current, date
+ }
+`);
+
+// Total count for pagination UI
+export const ARTICLES_COUNT_QUERY = defineQuery(`
+ count(*[_type == "article" && defined(slug.current)])
+`);
+```
+
+### Listing Page
+```typescript
+const ENTRIES_PER_PAGE = 10;
+
+export default async function BlogPage({
+ searchParams
+}: {
+ searchParams: Promise<{ page?: string }>
+}) {
+ const { page: pageParam } = await searchParams;
+ const page = parseInt(pageParam || "1");
+ const start = (page - 1) * ENTRIES_PER_PAGE;
+ const end = start + ENTRIES_PER_PAGE;
+
+ const [{ data: articles }, { data: total }] = await Promise.all([
+ sanityFetch({ query: ARTICLES_QUERY, params: { start, end } }),
+ sanityFetch({ query: ARTICLES_COUNT_QUERY })
+ ]);
+
+ const totalPages = Math.ceil(total / ENTRIES_PER_PAGE);
+
+ return (
+
+ {articles.map(article => (
+
+ ))}
+
+
+ );
+}
+```
diff --git a/.agents/skills/sanity-best-practices/references/nuxt.md b/.agents/skills/sanity-best-practices/references/nuxt.md
new file mode 100644
index 000000000..4169ccff7
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/nuxt.md
@@ -0,0 +1,84 @@
+---
+title: Nuxt & Sanity Integration Rules
+description: Integration guide for Nuxt, including @nuxtjs/sanity, visual editing, and data fetching.
+---
+
+# Nuxt & Sanity Integration Rules
+
+## 1. Setup & Configuration
+
+### Configuration (`nuxt.config.ts`)
+Use the official `@nuxtjs/sanity` module.
+
+**Important:** Ensure the `minimal` client is NOT enabled if you want full features.
+
+```typescript
+export default defineNuxtConfig({
+ modules: ["@nuxtjs/sanity"],
+ sanity: {
+ projectId: process.env.NUXT_SANITY_PROJECT_ID,
+ dataset: process.env.NUXT_SANITY_DATASET,
+ apiVersion: "2026-02-01",
+ // Live Visual Editing Configuration
+ visualEditing: {
+ studioUrl: process.env.NUXT_SANITY_STUDIO_URL,
+ token: process.env.NUXT_SANITY_API_READ_TOKEN, // Required for fetching drafts
+ stega: true, // Enable stega for visual editing
+ mode: 'live-visual-editing', // Default: enables live updates
+ },
+ },
+});
+```
+
+## 2. Data Fetching
+
+### `useSanityQuery`
+Use the composable provided by the module for reactive fetching. It automatically handles preview state when configured.
+
+```vue
+
+
+
+
+
+```
+
+## 3. Visual Editing (Live Preview)
+
+### Automatic Setup
+When `visualEditing` is configured in `nuxt.config.ts`, the module handles:
+1. Injecting the Visual Editing overlays.
+2. Refreshing data when content changes in the Studio.
+3. Enabling Stega encoding.
+
+### Handling Stega in Logic
+Just like Next.js, if you use stega-encoded strings in logic (e.g. `v-if="post.layout === 'full'"`), you must clean them.
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+const layout = computed(() => stegaClean(props.layout));
+```
+
+## 4. Components
+
+### Portable Text
+Use the `` component (if installed via `@portabletext/vue` or provided by the module).
+
+```vue
+
+```
+
+### Images
+Use `@sanity/image-url` helper or a dedicated image component.
+
+```typescript
+import imageUrlBuilder from '@sanity/image-url'
+const builder = imageUrlBuilder(useSanity().client)
+// ... url generation logic
+```
diff --git a/.agents/skills/sanity-best-practices/references/page-builder.md b/.agents/skills/sanity-best-practices/references/page-builder.md
new file mode 100644
index 000000000..c9d64525f
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/page-builder.md
@@ -0,0 +1,307 @@
+---
+title: "Sanity Page Builder Patterns"
+description: Patterns for Sanity Page Builder arrays, block components, and live editing.
+---
+
+# Sanity Page Builder Patterns
+
+This guide covers **Page Builder** patterns—arrays of block objects that allow content teams to compose flexible page layouts. For Portable Text (rich text within documents), see `portable-text.md`.
+
+## 1. What is a Page Builder?
+
+A page builder is an **array of objects** (`pageBuilder[]`) that allows content teams to compose pages from reusable blocks without developer intervention.
+
+**When to use:**
+- Flexible layouts needed (marketing pages, landing pages)
+- Content can be reordered
+- Different components on different pages
+
+**When NOT to use:**
+- Rigid, formulaic content (blog posts, product pages)
+- Highly structured data that doesn't change layout
+- Rich text within a document body—use Portable Text instead
+
+## 2. Schema Organization
+
+### Directory Structure
+```
+schemaTypes/
+├── blocks/ # Page builder blocks (objects)
+│ ├── heroType.ts
+│ ├── featuresType.ts
+│ └── faqsType.ts
+├── pageBuilderType.ts # The array definition
+└── pageType.ts # Document using the page builder
+```
+
+### Objects vs References
+
+| Use **Objects** | Use **References** |
+|-----------------|-------------------|
+| Content is unique to this page | Content reused across many pages |
+| Simpler queries | Needs central management |
+| Default choice | FAQs, CTAs, testimonials |
+
+**Rule:** Use references sparingly. Most blocks should be objects.
+
+### Page Builder Array
+```typescript
+// pageBuilderType.ts
+import { defineType, defineArrayMember } from "sanity";
+
+export const pageBuilderType = defineType({
+ name: "pageBuilder",
+ type: "array",
+ of: [
+ defineArrayMember({ type: "hero" }),
+ defineArrayMember({ type: "splitImage" }),
+ defineArrayMember({ type: "features" }),
+ defineArrayMember({ type: "faqs" }),
+ ],
+ options: {
+ insertMenu: {
+ views: [
+ // Optional: Show visual thumbnails in the insert menu grid
+ { name: "grid", previewImageUrl: (type) => `/block-previews/${type}.png` },
+ ],
+ },
+ },
+});
+```
+
+### Block Preview Pattern
+Every block should have consistent previews:
+
+```typescript
+import { defineType } from "sanity";
+import { BlockContentIcon } from "@sanity/icons";
+
+export const splitImageType = defineType({
+ name: "splitImage",
+ type: "object",
+ icon: BlockContentIcon,
+ fields: [/* ... */],
+ preview: {
+ select: { title: "title", media: "image" },
+ prepare({ title, media }) {
+ return {
+ title: title || "Untitled",
+ subtitle: "Split Image", // Block type name
+ media: media ?? BlockContentIcon, // Fallback to icon
+ };
+ },
+ },
+});
+```
+
+## 3. Querying Page Builders
+
+Expand references only for blocks that need them:
+
+```groq
+*[_type == "page" && slug.current == $slug][0]{
+ ...,
+ content[]{
+ ...,
+ _type == "faqs" => {
+ ...,
+ faqs[]-> // Expand only FAQ references
+ }
+ }
+}
+```
+
+## 4. Rendering Page Builders
+
+### TypeScript Typing
+Use `Extract` to type individual blocks from the query result:
+
+```typescript
+import { PAGE_QUERYResult } from "@/sanity/types";
+
+type HeroProps = Extract<
+ NonNullable["content"]>[number],
+ { _type: "hero" }
+>;
+
+export function Hero({ title, image }: HeroProps) {
+ // Fully typed!
+}
+```
+
+### Switch-Based Rendering
+```typescript
+export function PageBuilder({ content }: { content: Block[] }) {
+ if (!Array.isArray(content)) return null;
+
+ return (
+
+ {content.map((block) => {
+ switch (block._type) {
+ case "hero":
+ return ;
+ case "features":
+ return ;
+ case "splitImage":
+ return ;
+ default:
+ return Unknown: {block._type}
;
+ }
+ })}
+
+ );
+}
+```
+
+**Always use `_key` for React keys:**
+```typescript
+// Breaks Visual Editing and causes hydration issues
+{items.map((item, i) => )}
+
+// Always use Sanity's _key
+{items.map((item) => )}
+```
+
+### Cleaning Values for Logic
+Use `stegaClean` when block fields control rendering logic:
+
+```typescript
+import { stegaClean } from "next-sanity";
+
+function SplitImage({ orientation, title, image }) {
+ return (
+
+ );
+}
+```
+
+## 5. Presentation Queries for Live Editing (Next.js)
+
+For faster live updates in the Presentation Tool, use **presentation queries** that fetch only the specific block being edited, rather than re-fetching the entire page.
+
+> **Note:** This pattern uses `usePresentationQuery` from `next-sanity/hooks`. For other frameworks, check your loader package for equivalent functionality.
+
+### The Pattern
+
+1. **Create a block-specific presentation query:**
+
+```typescript
+// queries.ts
+export const HERO_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ _id,
+ _type,
+ "heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
+ title,
+ subtitle,
+ image,
+ // ... all fields the component needs
+ }
+ }
+`)
+```
+
+2. **Use `usePresentationQuery` in your component:**
+
+```typescript
+'use client'
+import { usePresentationQuery } from 'next-sanity/hooks'
+import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
+
+type HeroProps = {
+ _key: string
+ documentId: string
+ // ... initial props from page query
+}
+
+export function Hero({ _key, documentId, ...initialProps }: HeroProps) {
+ // Fetch block-specific data for faster updates
+ const { data } = usePresentationQuery({
+ query: HERO_PRESENTATION_QUERY,
+ params: { documentId, blockKey: _key },
+ })
+
+ // Use presentation data if available, fallback to initial props
+ const blockData = data?.heroBlock || initialProps
+
+ return (
+
+ {blockData.title}
+ {/* ... */}
+
+ )
+}
+```
+
+### Why This Is Faster
+
+- **Without:** Editing a field triggers a full page re-render with all blocks
+- **With:** Only the specific block re-renders with its targeted query
+
+This pattern is especially valuable for pages with many blocks or complex nested data.
+
+**Note:** See `nextjs.md` for more details on `usePresentationQuery` and `visual-editing.md` for the conceptual overview.
+
+## 6. Page Builder Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Too many block variations | Split into separate blocks if >2 variants |
+| Paradox of choice | Limit blocks per document type |
+| Overusing references | Default to objects; references only for truly shared content |
+| Unused blocks accumulate | Prune regularly; see deprecation patterns |
+| Inconsistent previews | Always set title, subtitle (block name), and media/icon |
+
+## 7. Component Alignment Pattern
+Map Sanity "alignment" fields (usually string/select) to CSS classes using utility functions.
+
+**Schema:**
+```typescript
+defineField({
+ name: 'align',
+ type: 'string',
+ options: { list: ['left', 'center', 'right'], layout: 'radio' }
+})
+```
+
+**Implementation (Utility):**
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export function getTextAlign(align?: string) {
+ // CLEAN the value before switching!
+ switch (stegaClean(align)) {
+ case 'left': return 'text-left';
+ case 'right': return 'text-right';
+ default: return 'text-center';
+ }
+}
+```
+
+## 8. Semantic Heading Levels
+**Rule:** Do NOT store heading levels (h1, h2) in Sanity schema options. Determine them dynamically in the frontend to ensure accessibility.
+
+**Bad Schema:**
+```typescript
+// Don't do this
+{ name: 'level', type: 'string', options: { list: ['h1', 'h2'] } }
+```
+
+**Good Component:**
+Pass a `semanticLevel` prop based on the component's context/nesting.
+
+```typescript
+type Props = {
+ block: HeroBlock;
+ level?: 'h1' | 'h2' | 'h3'; // Default to h2 if undefined
+}
+
+export default function Section({ block, level = 'h2' }: Props) {
+ const Tag = level;
+ return {block.title};
+}
+```
+
+*Note: For Image patterns, see `image.md`. For Portable Text patterns, see `portable-text.md`.*
diff --git a/.agents/skills/sanity-best-practices/references/portable-text.md b/.agents/skills/sanity-best-practices/references/portable-text.md
new file mode 100644
index 000000000..843393837
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/portable-text.md
@@ -0,0 +1,365 @@
+---
+title: "Sanity Portable Text Rules"
+description: Portable Text (Rich Text) rendering and custom component creation for React/Next.js.
+---
+
+# Sanity Portable Text Rules
+
+Portable Text is Sanity's rich text format, used for content like article bodies (`body[]`). This guide covers rendering and creating custom PTE components.
+
+**Note:** For page-level layout blocks (`pageBuilder[]`), see `page-builder.md`.
+
+## 1. The Component
+Use the `PortableText` component from `next-sanity` (or `@portabletext/react`).
+
+```typescript
+import { PortableText } from "next-sanity";
+// or import { PortableText } from "@portabletext/react";
+
+export function Content({ value }: { value: any }) {
+ return ;
+}
+```
+
+## 2. Custom Components (`components` prop)
+**Always** define a typed components object to handle custom blocks, marks, and list styles.
+
+```typescript
+import { PortableTextComponents } from "next-sanity";
+
+const components: PortableTextComponents = {
+ // 1. Block styles (paragraphs, headings)
+ block: {
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ blockquote: ({ children }) => {children}
,
+ },
+
+ // 2. Custom types (non-text blocks like images, videos)
+ types: {
+ image: ({ value }) => ,
+ callToAction: ({ value }) => ,
+ },
+
+ // 3. Marks (inline decorators and annotations)
+ marks: {
+ strong: ({ children }) => {children},
+ em: ({ children }) => {children},
+ link: ({ children, value }) => {
+ const rel = !value.href.startsWith("/") ? "noreferrer noopener" : undefined;
+ return {children};
+ },
+ },
+
+ // 4. Lists
+ list: {
+ bullet: ({ children }) => ,
+ number: ({ children }) => {children}
,
+ },
+};
+```
+
+## 3. Component Categories
+
+Portable Text has three types of custom components, each with different patterns:
+
+| Type | Examples | Pattern |
+|------|----------|---------|
+| **Block styles** | h1, h2, blockquote, normal | Text blocks with `children` prop |
+| **Custom types** | image, video, callToAction | Non-text blocks with `value` prop |
+| **Marks** | link, strong, productRef | Inline annotations wrapping text |
+
+## 4. Creating Block Style Components
+
+Block styles are text blocks like headings and paragraphs. For simple styling, inline components work fine:
+
+```typescript
+block: {
+ h2: ({ children }) => {children}
,
+ normal: ({ children }) => {children}
,
+}
+```
+
+### With Visual Editing Support
+
+For live editing in the Presentation Tool, block style components may need **both** a client and server version:
+
+```typescript
+// Heading2.tsx (Server - simple SSR for production)
+export function Heading2({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+// Heading2Client.tsx (Client - for visual editing context)
+'use client'
+export function Heading2Client({ children, value }: { children: React.ReactNode; value: any }) {
+ // Can access block data via `value` for advanced patterns
+ return {children}
;
+}
+```
+
+Use `useIsPresentationTool` to conditionally render the client version:
+
+```typescript
+import { useIsPresentationTool } from 'next-sanity/hooks'
+
+function Heading2Wrapper(props) {
+ const isPresentationTool = useIsPresentationTool()
+
+ if (isPresentationTool) {
+ return
+ }
+ return
+}
+```
+
+## 5. Creating Custom Type Components
+
+Custom types are non-text blocks like images, videos, or CTAs embedded in rich text.
+
+### Schema Definition
+
+```typescript
+// schemaTypes/blocks/pteImageBlock.ts
+import { defineType, defineField } from 'sanity'
+
+export const pteImageBlock = defineType({
+ name: 'pteImage',
+ title: 'Image',
+ type: 'object',
+ fields: [
+ defineField({ name: 'image', type: 'image', options: { hotspot: true } }),
+ defineField({ name: 'caption', type: 'string' }),
+ defineField({ name: 'alt', type: 'string', validation: (r) => r.required() }),
+ ],
+ preview: {
+ select: { title: 'caption', media: 'image' },
+ },
+})
+```
+
+### Register in Body Schema
+
+```typescript
+defineField({
+ name: 'body',
+ type: 'array',
+ of: [
+ { type: 'block' }, // Standard text
+ { type: 'pteImage' }, // Custom image block
+ { type: 'pteVideo' }, // Custom video block
+ ],
+})
+```
+
+### Frontend Component
+
+```typescript
+// PteImageComponent.tsx
+'use client'
+
+type PteImageProps = {
+ value: {
+ _key: string
+ image: any
+ caption?: string
+ alt: string
+ }
+}
+
+export function PteImageComponent({ value }: PteImageProps) {
+ if (!value.image) return null
+
+ return (
+
+
+ {value.caption && (
+ {value.caption}
+ )}
+
+ )
+}
+
+// Register in components
+const components: PortableTextComponents = {
+ types: {
+ pteImage: PteImageComponent,
+ },
+}
+```
+
+## 6. Creating Mark Components
+
+Marks are inline annotations that wrap text—links, highlights, or custom references.
+
+### Schema Definition (Annotation)
+
+```typescript
+// In your block configuration
+defineField({
+ name: 'body',
+ type: 'array',
+ of: [
+ {
+ type: 'block',
+ marks: {
+ decorators: [
+ { title: 'Strong', value: 'strong' },
+ { title: 'Emphasis', value: 'em' },
+ { title: 'Highlight', value: 'highlight' },
+ ],
+ annotations: [
+ {
+ name: 'link',
+ type: 'object',
+ title: 'Link',
+ fields: [
+ { name: 'href', type: 'url', title: 'URL' },
+ { name: 'openInNewTab', type: 'boolean', title: 'Open in new tab' },
+ ],
+ },
+ {
+ name: 'productRef',
+ type: 'object',
+ title: 'Product Reference',
+ fields: [
+ { name: 'product', type: 'reference', to: [{ type: 'product' }] },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+})
+```
+
+### Frontend Component
+
+```typescript
+// LinkMark.tsx
+type LinkMarkProps = {
+ children: React.ReactNode
+ value: {
+ href: string
+ openInNewTab?: boolean
+ }
+}
+
+export function LinkMark({ children, value }: LinkMarkProps) {
+ const { href, openInNewTab } = value
+ const target = openInNewTab ? '_blank' : undefined
+ const rel = openInNewTab ? 'noopener noreferrer' : undefined
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Register in components
+const components: PortableTextComponents = {
+ marks: {
+ link: LinkMark,
+ highlight: ({ children }) => {children},
+ },
+}
+```
+
+## 7. Presentation Queries for PTE Blocks
+
+For faster live editing of custom PTE blocks, use presentation queries that fetch only the specific block:
+
+```typescript
+// queries.ts
+export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ _id,
+ _type,
+ "pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
+ _key,
+ image,
+ caption,
+ alt
+ }
+ }
+`)
+```
+
+Then in your component:
+
+```typescript
+'use client'
+import { usePresentationQuery } from 'next-sanity/hooks'
+
+export function PteImageComponent({ value, documentId }: { value: any; documentId?: string }) {
+ const { data } = usePresentationQuery({
+ query: PTE_IMAGE_PRESENTATION_QUERY,
+ params: { documentId, blockKey: value._key },
+ })
+
+ const blockData = data?.pteImageBlock || value
+
+ // ... render with blockData
+}
+```
+
+**Note:** You'll need to pass `documentId` through to your PTE components. See `visual-editing.md` for context patterns.
+
+## 8. GROQ Fragment for PTE
+
+When querying documents with Portable Text, expand custom blocks:
+
+```groq
+*[_type == "article" && slug.current == $slug][0]{
+ ...,
+ body[]{
+ ...,
+ _type == "pteImage" => {
+ ...,
+ "imageUrl": image.asset->url
+ },
+ _type == "pteVideo" => {
+ ...,
+ video->{ title, url }
+ }
+ }
+}
+```
+
+## 9. Stega and Visual Editing
+
+When Visual Editing is enabled, text content contains invisible stega characters for click-to-edit functionality.
+
+**For text rendering:** Let stega characters pass through—they enable overlays:
+```typescript
+// Good - stega preserved for click-to-edit
+{children}
+```
+
+**For logic/comparisons:** Clean the values first:
+```typescript
+import { stegaClean } from '@sanity/client/stega'
+
+// Clean before using in logic
+const cleanedStyle = stegaClean(block.style)
+if (cleanedStyle === 'h2') { ... }
+```
+
+## 10. Type Safety
+When using TypeGen, the Portable Text value usually has a complex generated type. You can often use `any` or `PortableTextBlock[]` for the *prop*, but cast specific blocks if needed.
+
+```typescript
+import { PortableTextBlock } from "next-sanity";
+
+type Props = {
+ value: PortableTextBlock[];
+};
+```
+
+## 11. Best Practices
+
+- **Tailwind Typography:** For simple blogs, wrap `` in a `` (from `@tailwindcss/typography`) instead of manually styling every block.
+- **Handling Nulls:** Always check if `value` exists and is an array before rendering.
+- **Keys:** The `PortableText` component handles React keys automatically using the `_key` from Sanity. Do not add keys manually.
+- **Separate from Page Builder:** PTE blocks live in `body[]` (rich text fields), not `pageBuilder[]` (page layout). Keep these patterns separate.
diff --git a/.agents/skills/sanity-best-practices/references/project-structure.md b/.agents/skills/sanity-best-practices/references/project-structure.md
new file mode 100644
index 000000000..03a6e5bcf
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/project-structure.md
@@ -0,0 +1,116 @@
+---
+title: Sanity Project Structure
+description: Project structure patterns for Sanity projects including monorepo and embedded Studio setups.
+---
+
+# Sanity Project Structure
+
+## Standalone Studio
+
+Best for content-only projects, API-first architectures, or when frontend is managed separately.
+
+```
+your-project/
+├── schemaTypes/
+│ ├── index.ts
+│ ├── documents/
+│ ├── objects/
+│ └── blocks/
+├── sanity.config.ts
+├── sanity.cli.ts
+└── package.json
+```
+
+**Use cases:**
+- Content modeling with MCP/AI tools (no frontend needed)
+- Headless CMS with external consumers
+- Prototyping and content design
+
+## Embedded Studio (Recommended for Next.js)
+
+Best for most Next.js projects. Unified deployment, simpler setup.
+
+```
+your-project/
+├── src/
+│ ├── app/ # Next.js App Router
+│ │ └── studio/[[...tool]]/ # Embedded Studio route
+│ └── sanity/
+│ ├── lib/
+│ │ ├── client.ts
+│ │ ├── live.ts # defineLive setup
+│ │ └── queries.ts
+│ └── schemaTypes/
+│ ├── index.ts
+│ ├── documents/
+│ ├── objects/
+│ └── blocks/
+├── sanity.config.ts
+├── sanity.cli.ts # CLI + TypeGen configuration
+└── sanity.types.ts # Generated types (from TypeGen)
+```
+
+## Monorepo
+
+Best when you need separation of concerns, multiple frontends, or strict dependency isolation.
+
+```
+your-project/
+├── apps/
+│ ├── studio/ # Sanity Studio (standalone)
+│ │ ├── src/
+│ │ │ └── schemaTypes/
+│ │ │ ├── index.ts
+│ │ │ ├── documents/
+│ │ │ ├── objects/
+│ │ │ └── blocks/
+│ │ ├── sanity.config.ts
+│ │ ├── sanity.cli.ts
+│ │ └── package.json
+│ └── web/ # Next.js (or other framework)
+│ ├── src/
+│ │ ├── app/
+│ │ └── sanity/
+│ │ ├── client.ts
+│ │ ├── live.ts
+│ │ └── queries.ts
+│ └── package.json
+├── pnpm-workspace.yaml
+└── package.json
+```
+
+**Setup:**
+1. Add web app URL to CORS origins in [Sanity Manage](https://www.sanity.io/manage)
+2. Configure `typegen` in `sanity.cli.ts` to read schema from `apps/studio` and output types to `apps/web`
+
+## File Naming Conventions
+
+- **kebab-case** for all files: `user-profile.ts`, `hero-block.ts`
+- `.ts` for schemas/utilities, `.tsx` for React components
+- Each schema exports a named const matching filename
+
+## Schema Directory Structure
+
+```
+schemaTypes/
+├── index.ts # Exports all types
+├── documents/ # Standalone content types
+│ ├── post.ts
+│ └── author.ts
+├── objects/ # Embeddable/reusable types
+│ ├── seo.ts
+│ └── link.ts
+├── blocks/ # Portable Text blocks
+│ ├── hero.ts
+│ └── callout.ts
+└── shared/ # Shared field definitions
+ └── seoFields.ts
+```
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `sanity.config.ts` | Studio configuration (plugins, schema, structure) |
+| `sanity.cli.ts` | CLI configuration (project ID, dataset, TypeGen config) |
+| `structure.ts` | Custom desk structure |
diff --git a/.agents/skills/sanity-best-practices/references/remix.md b/.agents/skills/sanity-best-practices/references/remix.md
new file mode 100644
index 000000000..5ee232a52
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/remix.md
@@ -0,0 +1,134 @@
+---
+title: React Router (Remix) & Sanity Integration Rules
+description: Integration guide for React Router (formerly Remix) with Sanity, including Loaders and Visual Editing.
+---
+
+# React Router (Remix) & Sanity Integration Rules
+
+## Version Note
+
+This guide covers both:
+- **Remix v2** (`@remix-run/*` packages)
+- **React Router v7** (the successor to Remix, `react-router` package)
+
+The Sanity integration pattern is the same for both. Import paths differ slightly:
+
+| Remix v2 | React Router v7 |
+|----------|-----------------|
+| `@remix-run/node` | `react-router` |
+| `@remix-run/react` | `react-router` |
+| `remix.config.js` | `react-router.config.ts` |
+
+The examples below use Remix v2 imports. Adjust if using React Router v7.
+
+## 1. Setup & Client Pattern
+
+To support both server-side fetching and client-side live previews, use the **Split Loader Pattern**.
+
+### A. Shared Loader (`app/sanity/loader.ts`)
+Defines the store config (SSR enabled, client deferred).
+
+```typescript
+import { createQueryStore } from '@sanity/react-loader'
+
+export const {
+ loadQuery,
+ setServerClient,
+ useQuery,
+ useLiveMode,
+} = createQueryStore({ client: false, ssr: true })
+```
+
+### B. Server Loader (`app/sanity/loader.server.ts`)
+Initializes the server client.
+
+```typescript
+import { createClient } from '@sanity/client'
+import { loadQuery, setServerClient } from './loader'
+
+const client = createClient({
+ projectId: process.env.SANITY_PROJECT_ID,
+ dataset: process.env.SANITY_DATASET,
+ useCdn: true,
+ apiVersion: '2026-02-01',
+ stega: {
+ enabled: true,
+ studioUrl: 'https://my-studio-url.com',
+ },
+})
+
+setServerClient(client)
+
+export { loadQuery }
+```
+
+## 2. Data Fetching (Loaders)
+
+Use `loadQuery` from your **server** file in route loaders.
+
+```typescript
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { useLoaderData } from "@remix-run/react";
+import { loadQuery } from "~/sanity/loader.server";
+import { POSTS_QUERY } from "~/sanity/queries";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const initial = await loadQuery(POSTS_QUERY, params);
+ return { initial, query: POSTS_QUERY, params };
+}
+
+export default function Index() {
+ const { initial, query, params } = useLoaderData
();
+ // ... pass to component
+}
+```
+
+## 3. Real-time Preview & Visual Editing
+
+### A. Use `useQuery` in Components
+Import `useQuery` from your **shared** loader file.
+
+```typescript
+import { useQuery } from "~/sanity/loader";
+
+export default function Page() {
+ const { initial, query, params } = useLoaderData();
+
+ const { data, encodeDataAttribute } = useQuery(query, params, {
+ initial
+ });
+
+ return (
+
+ {data?.title}
+
+ );
+}
+```
+
+### B. Enable Live Mode (`VisualEditing.tsx`)
+Create a component to handle the connection.
+
+```typescript
+import { enableVisualEditing } from '@sanity/visual-editing'
+import { useLiveMode } from '~/sanity/loader'
+import { client } from '~/sanity/client' // Your browser-safe client
+import { useEffect } from 'react'
+
+export default function VisualEditing() {
+ useEffect(() => enableVisualEditing(), [])
+ useLiveMode({ client })
+ return null
+}
+```
+
+Render this component in `root.tsx` only when valid (e.g., check env vars or user session).
+
+## 4. Stega Cleaning
+When using data for logic (routing, classNames), use `stegaClean`.
+
+```typescript
+import { stegaClean } from "@sanity/client/stega"
+// ...
+if (stegaClean(slug) === 'home') { ... }
+```
diff --git a/.agents/skills/sanity-best-practices/references/schema.md b/.agents/skills/sanity-best-practices/references/schema.md
new file mode 100644
index 000000000..33d650d3f
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/schema.md
@@ -0,0 +1,378 @@
+---
+title: Sanity Schema Best Practices
+description: Rules for defining Sanity Content Models (Schemas), including field definitions, strict typing, and validation patterns.
+---
+
+# Sanity Schema Best Practices
+
+Use this contents list to jump to the schema design decision you are making.
+
+## Table of Contents
+
+- Core philosophy: data over presentation
+- Strict definition syntax
+- Shared fields pattern
+- Field patterns
+- References vs nested objects
+- Safe schema updates
+- Validation patterns
+
+## 1. Core Philosophy: Data > Presentation
+Model **what things are**, not **what they look like**.
+- ❌ **Bad:** `bigHeroText`, `redButton`, `threeColumnRow`, `color`, `fontSize`
+- ✅ **Good:** `heroStatement`, `callToAction`, `featuresSection`, `status`, `role`
+
+**The test:** "If we redesigned the site, would this field name still make sense?"
+- `threeColumnLayout` → ❌ Fails (what if we go to 2 columns?)
+- `features` → ✅ Passes (features are features regardless of layout)
+
+## 2. Strict Definition Syntax
+Always use the helper functions from `sanity` for type safety and autocompletion.
+
+- **ALWAYS** use `defineType` for the root export.
+- **ALWAYS** use `defineField` for fields.
+- **ALWAYS** use `defineArrayMember` for items inside arrays.
+
+```typescript
+import { defineType, defineField, defineArrayMember } from 'sanity'
+import { TagIcon } from '@sanity/icons'
+
+export const article = defineType({
+ name: 'article',
+ title: 'Article',
+ type: 'document',
+ icon: TagIcon,
+ fields: [
+ defineField({
+ name: 'title',
+ type: 'string',
+ validation: (rule) => rule.required(),
+ }),
+ defineField({
+ name: 'tags',
+ type: 'array',
+ of: [
+ // ALWAYS use defineArrayMember for array items
+ defineArrayMember({ type: 'reference', to: [{ type: 'tag' }] })
+ ]
+ })
+ ]
+})
+```
+
+## 3. Shared Fields Pattern
+Export arrays of fields to reuse common patterns (e.g., SEO, standard page headers).
+
+```typescript
+// src/schemaTypes/shared/seoFields.ts
+export const seoFields = [
+ defineField({ name: 'seoTitle', type: 'string', title: 'SEO Title' }),
+ defineField({ name: 'seoDesc', type: 'text', title: 'SEO Description' })
+]
+
+// Usage
+defineType({
+ name: 'page',
+ fields: [
+ defineField({ name: 'title', type: 'string' }),
+ ...seoFields // Spread shared fields
+ ]
+})
+```
+
+## 4. Field Patterns
+
+### A. Array Keys (`_key`)
+Every item in a Sanity array automatically gets a `_key` property. This is **critical** for:
+- React reconciliation (use as `key` prop)
+- Visual Editing overlays (click-to-edit)
+- Portable Text rendering
+
+**Schema:** Sanity auto-generates `_key` for array items. You don't define it.
+
+**Frontend:** Always use `_key` as React's `key`:
+```typescript
+// ✅ Correct
+{items.map((item) => )}
+
+// ❌ Wrong - index keys break Visual Editing
+{items.map((item, i) => )}
+```
+
+**Querying:** Always include `_key` in array projections:
+```groq
+*[_type == "page"][0]{
+ pageBuilder[]{
+ _key, // Always include _key in queries
+ _type,
+ ...
+ }
+}
+```
+
+### B. Icons
+Always assign an icon from `@sanity/icons` to documents and objects. This improves the Studio UX significantly. Browse all icons at [icons.sanity.build](https://icons.sanity.build/all).
+
+| Content Type | Icon |
+|--------------|------|
+| Article, Post | `DocumentTextIcon` |
+| Author, Person | `UserIcon` |
+| Category, Tag | `TagIcon` |
+| Settings | `CogIcon` |
+| Page | `DocumentIcon` |
+| Image block | `ImageIcon` |
+| Video block | `PlayIcon` |
+| FAQ | `HelpCircleIcon` |
+| Link | `LinkIcon` |
+
+### C. Boolean vs. List
+Avoid boolean fields for binary states that might expand later.
+- **Prefer:** `options.list` with "radio" layout.
+
+```typescript
+defineField({
+ name: 'status',
+ type: 'string',
+ options: {
+ list: [
+ { title: 'Draft', value: 'draft' },
+ { title: 'Published', value: 'published' }
+ ],
+ layout: 'radio'
+ }
+})
+```
+
+### D. The "Toggle" Pattern (Conditional Fields)
+Use a radio/boolean field to toggle visibility of other fields (often grouped in fieldsets).
+
+```typescript
+defineField({
+ name: 'linkType',
+ type: 'string',
+ options: { list: ['internal', 'external'], layout: 'radio' }
+}),
+defineField({
+ name: 'internalLink',
+ type: 'reference',
+ hidden: ({ parent }) => parent?.linkType !== 'internal'
+}),
+defineField({
+ name: 'externalUrl',
+ type: 'url',
+ hidden: ({ parent }) => parent?.linkType !== 'external'
+})
+```
+
+## 5. References vs Nested Objects
+
+A **critical modeling decision**: when to use `reference` vs embedding an `object`.
+
+### Use References When:
+- Content is **reusable** across documents (authors, categories, products)
+- Content needs its **own editing interface** in Studio
+- You need to query/filter by the related content independently
+- Multiple documents should share the **same instance** (update once, reflect everywhere)
+
+```typescript
+// ✅ Author is reusable and independently editable
+defineField({
+ name: 'author',
+ type: 'reference',
+ to: [{ type: 'author' }]
+})
+```
+
+### Use Nested Objects When:
+- Content is **specific to this document** (not shared)
+- Content doesn't make sense on its own (address, SEO metadata)
+- You want **simpler editing** (all fields in one place)
+- You need the data to be **copied** not linked
+
+```typescript
+// ✅ SEO is document-specific, not shared
+defineField({
+ name: 'seo',
+ type: 'object',
+ fields: [
+ defineField({ name: 'title', type: 'string' }),
+ defineField({ name: 'description', type: 'text' })
+ ]
+})
+```
+
+### Quick Decision Matrix
+
+| Scenario | Use |
+|----------|-----|
+| Blog post author | `reference` (reusable) |
+| Product category | `reference` (shared taxonomy) |
+| Page SEO fields | `object` (page-specific) |
+| Hero section content | `object` (page-specific) |
+| Team member on About page | `reference` (might be used elsewhere) |
+| Call-to-action button | `object` (usually page-specific) |
+
+### Querying Differences
+```groq
+// Reference requires expansion
+*[_type == "post"]{ author->{ name, bio } }
+
+// Object is already inline
+*[_type == "post"]{ seo { title, description } }
+```
+
+## 6. Safe Schema Updates (The Deprecation Pattern)
+
+**NEVER** delete a field that contains production data. It will cause data loss or Studio crashes. Instead, follow the **ReadOnly -> Hidden -> Deprecated** lifecycle.
+
+### The Pattern
+1. **`deprecated`**: Adds a visual warning and reason.
+2. **`readOnly: true`**: Prevents new edits but keeps data visible.
+3. **`hidden`**: Hides it from *new* documents (where value is undefined).
+4. **`initialValue: undefined`**: Ensures new documents don't get this field.
+
+```typescript
+defineField({
+ name: 'oldTitle', // The field you want to remove
+ title: 'Article Title (Deprecated)',
+ type: 'string',
+ deprecated: {
+ reason: 'Use the new "seoTitle" field instead. This will be removed in v2.'
+ },
+ readOnly: true,
+ hidden: ({ value }) => value === undefined,
+ initialValue: undefined
+})
+```
+
+### Migration Workflow
+
+**Phase 1: Deprecate** — Apply the deprecation pattern above. Deploy.
+
+**Phase 2: Migrate** — Update frontend to use new fields (with `coalesce()` fallbacks). Create a migration:
+
+```typescript
+// migrations/rename-oldTitle-to-newTitle/index.ts
+import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'
+
+export default defineMigration({
+ title: 'Rename oldTitle to newTitle',
+ documentTypes: ['article'],
+ filter: 'defined(oldTitle) && !defined(newTitle)',
+ migrate: {
+ document(doc) {
+ if (!doc.oldTitle || doc.newTitle) return
+ return [
+ at('newTitle', setIfMissing(doc.oldTitle)),
+ at('oldTitle', unset())
+ ]
+ }
+ }
+})
+```
+
+```bash
+# Dry run first (default)
+sanity migration run rename-oldTitle-to-newTitle
+
+# Execute when ready
+sanity migration run rename-oldTitle-to-newTitle --no-dry-run
+```
+
+**Phase 3: Remove** — Once `oldTitle` is undefined for all documents, delete the field definition.
+
+## 7. Validation Patterns
+
+Beyond `rule.required()`, Sanity offers powerful validation options.
+
+### Common Patterns
+
+```typescript
+// Email validation
+defineField({
+ name: 'email',
+ type: 'string',
+ validation: (rule) => rule.email().required()
+})
+
+// URL validation (with custom message)
+defineField({
+ name: 'website',
+ type: 'url',
+ validation: (rule) => rule.uri({
+ scheme: ['http', 'https']
+ }).error('Must be a valid URL starting with http:// or https://')
+})
+
+// Length constraints
+defineField({
+ name: 'excerpt',
+ type: 'text',
+ validation: (rule) => rule.max(200).warning('Keep it under 200 characters for best SEO')
+})
+
+// Regex pattern
+defineField({
+ name: 'slug',
+ type: 'slug',
+ validation: (rule) => rule.required().custom((slug) => {
+ if (!slug?.current) return 'Required'
+ if (!/^[a-z0-9-]+$/.test(slug.current)) {
+ return 'Slug must be lowercase with hyphens only'
+ }
+ return true
+ })
+})
+```
+
+### Cross-Field Validation
+
+```typescript
+defineField({
+ name: 'endDate',
+ type: 'datetime',
+ validation: (rule) => rule.custom((endDate, context) => {
+ const startDate = context.document?.startDate
+ if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
+ return 'End date must be after start date'
+ }
+ return true
+ })
+})
+```
+
+### Array Validation
+
+```typescript
+defineField({
+ name: 'tags',
+ type: 'array',
+ of: [{ type: 'string' }],
+ validation: (rule) => rule
+ .min(1).error('Add at least one tag')
+ .max(10).warning('Too many tags may hurt SEO')
+ .unique()
+})
+```
+
+### Async Validation (Uniqueness Check)
+
+```typescript
+defineField({
+ name: 'slug',
+ type: 'slug',
+ validation: (rule) => rule.required().custom(async (slug, context) => {
+ if (!slug?.current) return true
+
+ const client = context.getClient({ apiVersion: '2026-02-01' })
+ const id = context.document?._id?.replace(/^drafts\./, '')
+
+ const existing = await client.fetch(
+ `count(*[_type == "post" && slug.current == $slug && _id != $id])`,
+ { slug: slug.current, id }
+ )
+
+ return existing === 0 || 'Slug already exists'
+ })
+})
+```
diff --git a/.agents/skills/sanity-best-practices/references/seo.md b/.agents/skills/sanity-best-practices/references/seo.md
new file mode 100644
index 000000000..503127530
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/seo.md
@@ -0,0 +1,331 @@
+---
+title: Sanity SEO Best Practices
+description: SEO best practices for Sanity with Next.js, including metadata, Open Graph, sitemaps, redirects, and JSON-LD structured data.
+---
+
+# Sanity SEO Best Practices
+
+## 1. Core Philosophy
+
+SEO doesn't require complex configurations. A few core principles, applied consistently:
+
+- **Smart defaults with optional overrides** — Don't require SEO fields; use existing content as fallback
+- **Use GROQ for fallback logic** — Move conditional logic into queries, not components
+- **Leverage Next.js APIs** — Use `generateMetadata`, `sitemap.ts`, not manual `` tags
+- **Structured content = structured data** — Your content model is already SEO-ready
+
+## 2. SEO Schema Type (Reusable)
+
+Create a reusable SEO object type for consistent metadata across document types.
+
+```typescript
+// schemaTypes/seoType.ts
+import { defineField, defineType } from "sanity";
+
+export const seoType = defineType({
+ name: "seo",
+ title: "SEO",
+ type: "object",
+ fields: [
+ defineField({
+ name: "title",
+ description: "Overrides the page title if provided",
+ type: "string",
+ }),
+ defineField({
+ name: "description",
+ type: "text",
+ rows: 3,
+ }),
+ defineField({
+ name: "image",
+ description: "Image for social sharing (1200x630 recommended)",
+ type: "image",
+ options: { hotspot: true },
+ }),
+ defineField({
+ name: "noIndex",
+ description: "Hide this page from search engines",
+ type: "boolean",
+ initialValue: false,
+ }),
+ ],
+});
+```
+
+**Usage in document types:**
+```typescript
+defineField({
+ name: "seo",
+ type: "seo",
+})
+```
+
+## 3. GROQ Queries with Fallbacks
+
+Use `coalesce()` to provide fallback values. This keeps frontend logic clean.
+
+```groq
+*[_type == "page" && slug.current == $slug][0]{
+ ...,
+ "seo": {
+ // Use SEO field if provided, otherwise fall back to main title
+ "title": coalesce(seo.title, title, ""),
+ "description": coalesce(seo.description, ""),
+ "image": seo.image,
+ "noIndex": seo.noIndex == true
+ }
+}
+```
+
+**Key principle:** `seo.title` will never be `null` — it contains either the SEO override, the page title, or empty string.
+
+## 4. Next.js Metadata (The Right Way)
+
+Use `generateMetadata` — never render `` or `` tags directly in components.
+
+```typescript
+// app/(frontend)/[slug]/page.tsx
+import type { Metadata } from "next";
+import { urlFor } from "@/sanity/lib/image";
+
+type RouteProps = {
+ params: Promise<{ slug: string }>;
+};
+
+// Extract fetch to reuse in both functions
+const getPage = async (params: RouteProps["params"]) =>
+ sanityFetch({
+ query: PAGE_QUERY,
+ params: await params,
+ stega: false, // Critical for SEO!
+ });
+
+export async function generateMetadata({ params }: RouteProps): Promise {
+ const { data: page } = await getPage(params);
+
+ if (!page) return {};
+
+ const metadata: Metadata = {
+ title: page.seo.title,
+ description: page.seo.description,
+ };
+
+ // Open Graph image
+ if (page.seo.image) {
+ metadata.openGraph = {
+ images: {
+ url: urlFor(page.seo.image).width(1200).height(630).url(),
+ width: 1200,
+ height: 630,
+ },
+ };
+ }
+
+ // noIndex robots directive
+ if (page.seo.noIndex) {
+ metadata.robots = "noindex";
+ }
+
+ return metadata;
+}
+
+export default async function Page({ params }: RouteProps) {
+ const { data: page } = await getPage(params);
+ // ... render page
+}
+```
+
+**Critical:** Always set `stega: false` when fetching for metadata. Stega characters in `` destroy SEO.
+
+## 5. Dynamic Sitemap
+
+Use Next.js `sitemap.ts` convention to auto-generate from Sanity content.
+
+### GROQ Query
+```groq
+*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true] {
+ "href": select(
+ _type == "page" => "/" + slug.current,
+ _type == "post" => "/posts/" + slug.current,
+ slug.current
+ ),
+ _updatedAt
+}
+```
+
+### Route Implementation
+```typescript
+// app/sitemap.ts
+import { MetadataRoute } from "next";
+import { client } from "@/sanity/lib/client";
+import { SITEMAP_QUERY } from "@/sanity/lib/queries";
+
+export default async function sitemap(): Promise {
+ const baseUrl = process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}`
+ : "http://localhost:3000";
+
+ try {
+ const paths = await client.fetch(SITEMAP_QUERY);
+ if (!paths) return [];
+
+ return paths.map((path) => ({
+ url: new URL(path.href!, baseUrl).toString(),
+ lastModified: new Date(path._updatedAt),
+ changeFrequency: "weekly",
+ priority: 1,
+ }));
+ } catch (error) {
+ console.error("Sitemap generation failed:", error);
+ return [];
+ }
+}
+```
+
+**Note:** Sitemap limit is 50,000 URLs per file. For larger sites, use sitemap index.
+
+## 6. Redirects (Managed in Sanity)
+
+Create a redirect document type for content team management.
+
+### Schema
+```typescript
+// schemaTypes/redirectType.ts
+import { defineField, defineType, SanityDocumentLike } from "sanity";
+import { LinkIcon } from "@sanity/icons";
+
+function isValidPath(value: string | undefined) {
+ if (!value) return "Required";
+ if (!value.startsWith("/")) return "Must start with /";
+ if (/[^a-zA-Z0-9\-_/:]/.test(value)) return "Invalid characters";
+ return true;
+}
+
+export const redirectType = defineType({
+ name: "redirect",
+ title: "Redirect",
+ type: "document",
+ icon: LinkIcon,
+ validation: (Rule) =>
+ Rule.custom((doc: SanityDocumentLike | undefined) => {
+ if (doc?.source === doc?.destination) {
+ return "Source and destination cannot be the same";
+ }
+ return true;
+ }),
+ fields: [
+ defineField({
+ name: "source",
+ type: "string",
+ validation: (Rule) => Rule.required().custom(isValidPath),
+ }),
+ defineField({
+ name: "destination",
+ type: "string",
+ validation: (Rule) => Rule.required(),
+ }),
+ defineField({
+ name: "permanent",
+ description: "301 (permanent) or 302 (temporary)",
+ type: "boolean",
+ initialValue: true,
+ }),
+ defineField({
+ name: "isEnabled",
+ type: "boolean",
+ initialValue: true,
+ }),
+ ],
+});
+```
+
+### Next.js Config
+```typescript
+// next.config.ts
+import { fetchRedirects } from "@/sanity/lib/fetchRedirects";
+
+const nextConfig: NextConfig = {
+ async redirects() {
+ return await fetchRedirects();
+ },
+};
+```
+
+**Limits:** Vercel allows max 1,024 redirects in `next.config`. For more, use middleware.
+
+## 7. Dynamic Open Graph Images
+
+Generate OG images on-the-fly using Next.js Edge Runtime at `/api/og`.
+
+```typescript
+// app/api/og/route.tsx
+import { ImageResponse } from "next/og";
+export const runtime = "edge";
+
+export async function GET(request: Request) {
+ const id = new URL(request.url).searchParams.get("id");
+ if (!id) return new Response("Missing id", { status: 400 });
+
+ const data = await client.fetch(`*[_id == $id][0]{ title }`, { id });
+
+ return new ImageResponse(
+
+
{data?.title || "Untitled"}
+ ,
+ { width: 1200, height: 630 }
+ );
+}
+```
+
+Use as fallback in metadata: `url: page.seo.image ? urlFor(page.seo.image).url() : \`/api/og?id=\${page._id}\``
+
+## 8. JSON-LD Structured Data
+
+Use `schema-dts` for type-safe structured data.
+
+```bash
+npm install schema-dts
+```
+
+### FAQ Example
+```typescript
+import { FAQPage, WithContext } from "schema-dts";
+
+const generateFaqData = (faqs: FAQ[]): WithContext => ({
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqs.map((faq) => ({
+ "@type": "Question",
+ name: faq.title,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: faq.text, // Use pt::text() in GROQ to get plain text
+ },
+ })),
+});
+
+// In component
+
+```
+
+### GROQ for Plain Text
+```groq
+faqs[]->{
+ _id,
+ title,
+ body,
+ "text": pt::text(body) // Convert Portable Text to plain string
+}
+```
+
+## 9. Testing Tools
+
+- **Open Graph:** [opengraph.ing](https://opengraph.ing/)
+- **Facebook:** [Sharing Debugger](https://developers.facebook.com/tools/debug/)
+- **Twitter:** [Card Validator](https://cards-dev.twitter.com/validator)
+- **LinkedIn:** [Post Inspector](https://www.linkedin.com/post-inspector/)
+- **Sitemap:** [XML Sitemaps Validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html)
diff --git a/.agents/skills/sanity-best-practices/references/studio-structure.md b/.agents/skills/sanity-best-practices/references/studio-structure.md
new file mode 100644
index 000000000..51bcd6d2b
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/studio-structure.md
@@ -0,0 +1,136 @@
+---
+title: "Sanity Studio Structure Rules"
+description: Rules for customizing the Sanity Studio Structure (S.structure).
+---
+
+# Sanity Studio Structure Rules
+
+## 1. Setup
+Custom structure is defined in `sanity.config.ts` using the `structureTool`.
+
+```typescript
+import { structureTool } from 'sanity/structure'
+import { structure } from './src/structure'
+
+export default defineConfig({
+ // ...
+ plugins: [
+ structureTool({ structure })
+ ]
+})
+```
+
+## 2. Structure Definition
+**Location:** `src/structure/index.ts`
+
+Use a function that receives `S` (StructureBuilder).
+
+```typescript
+import type { StructureResolver } from 'sanity/structure'
+
+export const structure: StructureResolver = (S) =>
+ S.list()
+ .title('Content')
+ .items([
+ // ... items
+ ])
+```
+
+## 3. Organization Principles
+1. **Singletons First:** Place critical site-wide settings (Global Settings, Homepage) at the top.
+2. **Dividers:** Use `S.divider()` to visually separate logical groups.
+3. **Filtered Lists:** Always exclude Singleton documents from generic `documentTypeList` items to avoid duplication.
+
+## 4. Singleton Pattern (Critical)
+
+**Singletons are enforced via Structure, NOT schema options.** There is no `singleton: true` schema option.
+
+### How Singletons Work
+1. Use `S.document().documentId('fixed-id')` to lock the document to a specific ID.
+2. Filter the type from generic lists to prevent duplicate entries.
+
+### Singleton Helper Function
+```typescript
+// Helper to create singleton list items
+function createSingleton(S: StructureBuilder, typeName: string, title: string, icon?: ComponentType) {
+ return S.listItem()
+ .title(title)
+ .icon(icon)
+ .child(
+ S.document()
+ .schemaType(typeName)
+ .documentId(typeName) // Fixed ID = singleton
+ .title(title)
+ )
+}
+
+// Usage
+createSingleton(S, 'settings', 'Site Settings', CogIcon)
+```
+
+### Querying Singletons
+```groq
+// By fixed ID (most efficient)
+*[_id == "settings"][0]
+
+// By type (works but slower)
+*[_type == "settings"][0]
+```
+
+**For localized singletons** (e.g., homepage per language), see `localization.md` Section 6.
+
+## 5. Implementation Pattern
+
+```typescript
+// Define singleton types to exclude from generic lists
+const SINGLETONS = ['settings', 'homePage']
+
+export const structure: StructureResolver = (S) =>
+ S.list()
+ .title('Website Content')
+ .items([
+ // 1. Singletons
+ S.listItem()
+ .title('Site Settings')
+ .icon(CogIcon)
+ .child(S.document().schemaType('settings').documentId('settings')),
+
+ S.divider(),
+
+ // 2. Content Verticals
+ S.listItem()
+ .title('Blog')
+ .child(
+ S.list()
+ .title('Blog Content')
+ .items([
+ S.documentTypeListItem('post').title('Posts'),
+ S.documentTypeListItem('author').title('Authors'),
+ ])
+ ),
+
+ S.divider(),
+
+ // 3. Remaining Documents (Filtered)
+ ...S.documentTypeListItems().filter(
+ (listItem) => !SINGLETONS.includes(listItem.getId() as string)
+ )
+ ])
+```
+
+## 6. Views (Split Pane)
+Add "Web Preview" or other views to documents.
+
+```typescript
+export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, { schemaType }) => {
+ switch (schemaType) {
+ case `post`:
+ return S.document().views([
+ S.view.form(), // Default form
+ S.view.component(PreviewComponent).title('Preview') // Custom view
+ ])
+ default:
+ return S.document().views([S.view.form()])
+ }
+}
+```
diff --git a/.agents/skills/sanity-best-practices/references/svelte.md b/.agents/skills/sanity-best-practices/references/svelte.md
new file mode 100644
index 000000000..aeca87d46
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/svelte.md
@@ -0,0 +1,155 @@
+---
+title: "SvelteKit & Sanity Integration Rules"
+description: Integration guide for SvelteKit with Sanity, including @sanity/svelte-loader, Visual Editing, and Preview Mode.
+---
+
+# SvelteKit & Sanity Integration Rules
+
+## 1. Setup & Configuration
+
+### Installation
+```bash
+npm install @sanity/svelte-loader @sanity/client @sanity/visual-editing
+```
+
+### Client Configuration (`src/lib/sanity.ts`)
+Define the client with `stega` enabled for the studio URL.
+
+```typescript
+import { createClient } from '@sanity/client'
+import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET, PUBLIC_SANITY_API_VERSION, PUBLIC_SANITY_STUDIO_URL } from '$env/static/public'
+
+export const client = createClient({
+ projectId: PUBLIC_SANITY_PROJECT_ID,
+ dataset: PUBLIC_SANITY_DATASET,
+ apiVersion: PUBLIC_SANITY_API_VERSION,
+ useCdn: true,
+ stega: {
+ studioUrl: PUBLIC_SANITY_STUDIO_URL,
+ },
+})
+```
+
+### Server Client (`src/lib/server/sanity.ts`)
+Use the read token for fetching preview content.
+
+```typescript
+import { SANITY_API_READ_TOKEN } from '$env/static/private'
+import { client } from '$lib/sanity'
+
+export const serverClient = client.withConfig({
+ token: SANITY_API_READ_TOKEN,
+ stega: true, // Optional: enable stega on server too if needed
+})
+```
+
+## 2. Hooks & Request Handler (Critical)
+
+You **must** configure `createRequestHandler` in `src/hooks.server.ts` to handle preview sessions and inject `loadQuery` into locals.
+
+```typescript
+// src/hooks.server.ts
+import { createRequestHandler, setServerClient } from '@sanity/svelte-loader'
+import { serverClient } from '$lib/server/sanity'
+
+setServerClient(serverClient)
+
+export const handle = createRequestHandler()
+```
+
+**Update `app.d.ts` types:**
+```typescript
+import type { LoaderLocals } from '@sanity/svelte-loader'
+
+declare global {
+ namespace App {
+ interface Locals extends LoaderLocals {}
+ }
+}
+```
+
+## 3. Preview State Propagation
+
+Pass the preview state from the server to the client via the root layout.
+
+**Server Layout (`src/routes/+layout.server.ts`):**
+```typescript
+import type { LayoutServerLoad } from './$types'
+
+export const load: LayoutServerLoad = ({ locals: { preview } }) => {
+ return { preview }
+}
+```
+
+**Client Layout (`src/routes/+layout.ts`):**
+```typescript
+import { setPreviewing } from '@sanity/svelte-loader'
+import type { LayoutLoad } from './$types'
+
+export const load: LayoutLoad = ({ data: { preview } }) => {
+ setPreviewing(preview)
+}
+```
+
+## 4. Data Fetching (Loaders)
+
+Use `locals.loadQuery` in your page server loaders.
+
+```typescript
+// src/routes/[slug]/+page.server.ts
+import type { PageServerLoad } from './$types'
+
+export const load: PageServerLoad = async ({ locals: { loadQuery }, params }) => {
+ const initial = await loadQuery(QUERY, params)
+ return { initial }
+}
+```
+
+## 5. Real-time Preview & Visual Editing
+
+### Component Usage (`useQuery`)
+Use `useQuery` in your Svelte component to handle real-time updates.
+
+```svelte
+
+
+
+{#if !loading && post}
+
+
+ {post.title}
+
+{/if}
+```
+
+### Enable Visual Editing (`+layout.svelte`)
+Enable Visual Editing and Live Mode in your root layout.
+
+```svelte
+
+
+
+```
diff --git a/.agents/skills/sanity-best-practices/references/typegen.md b/.agents/skills/sanity-best-practices/references/typegen.md
new file mode 100644
index 000000000..8635b7545
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/typegen.md
@@ -0,0 +1,215 @@
+---
+title: Sanity TypeGen Rules
+description: Workflow for generating TypeScript types from Sanity Schema and GROQ queries.
+---
+
+# Sanity TypeGen Rules
+
+## 1. The Workflow
+Sanity TypeGen generates TypeScript types from your schema and GROQ queries. Types can be generated automatically or manually.
+
+### Automatic (Recommended)
+Enable in `sanity.cli.ts` — types regenerate during `sanity dev` and `sanity build`:
+
+```typescript
+// sanity.cli.ts
+import { defineCliConfig } from 'sanity/cli'
+
+export default defineCliConfig({
+ typegen: {
+ enabled: true,
+ },
+})
+```
+
+### Manual
+Run the extract + generate cycle whenever schema or queries change:
+
+1. **Extract:** Converts your Schema (TS/JS) into a static JSON representation.
+2. **Generate:** Scans your codebase for GROQ queries and generates TypeScript types.
+
+```bash
+npx sanity schema extract && npx sanity typegen generate
+```
+
+### Watch Mode (for separate frontends)
+If your frontend is in a separate repo from the Studio, use watch mode:
+
+```bash
+npx sanity typegen generate --watch
+```
+
+## 2. The "Update Types" Pattern
+For manual workflows, implement a single script:
+
+**package.json:**
+```json
+"scripts": {
+ "typegen": "sanity schema extract && sanity typegen generate"
+}
+```
+
+### Git Strategy for Generated Files
+
+**Option A: Commit generated types (Recommended for most teams)**
+- Types available immediately after `git pull`
+- CI/CD doesn't need to run typegen
+- Can cause merge conflicts
+
+**Option B: Generate in CI (Recommended for larger teams)**
+Add to `.gitignore`:
+```gitignore
+# Sanity TypeGen (generated)
+sanity.types.ts
+schema.json
+```
+
+Then ensure CI runs typegen before build:
+```yaml
+# Example GitHub Actions
+- run: npm run typegen
+- run: npm run build
+```
+
+## 3. Configuration (`sanity.cli.ts`)
+
+> **Note:** `sanity-typegen.json` is deprecated. Move your configuration to `sanity.cli.ts`.
+
+```typescript
+// sanity.cli.ts
+import { defineCliConfig } from 'sanity/cli'
+
+export default defineCliConfig({
+ typegen: {
+ enabled: true, // Auto-generate during sanity dev/build
+ path: "./src/**/*.{ts,tsx,js,jsx,astro,svelte,vue}", // Glob to find queries
+ schema: "schema.json", // Schema file from extract
+ generates: "./sanity.types.ts", // Output file
+ overloadClientMethods: true, // Auto-type client.fetch() calls
+ },
+})
+```
+
+### Project Structure Examples
+
+**Single Repo / Embedded Studio (most common):**
+Use defaults — no extra config needed.
+
+**Monorepo** (Studio in `apps/studio`, Frontend in `apps/web`):
+```typescript
+export default defineCliConfig({
+ typegen: {
+ path: "../web/src/**/*.{ts,tsx,js,jsx}",
+ schema: "schema.json",
+ generates: "../web/sanity.types.ts",
+ },
+})
+```
+
+**Separate Repos:**
+Use `--watch` mode in your frontend: `sanity typegen generate --watch`
+
+## 4. Usage in Code
+
+### Automatic Type Inference (Recommended)
+With `overloadClientMethods: true` (default), `client.fetch()` automatically returns typed results when you use `defineQuery`:
+
+```typescript
+import { defineQuery } from "groq";
+import { createClient } from "@sanity/client";
+
+const client = createClient({...});
+
+const POSTS_QUERY = defineQuery(`*[_type == "post"]{ title, slug }`);
+
+// Return type is automatically inferred — no manual type import needed!
+const posts = await client.fetch(POSTS_QUERY);
+```
+
+### Manual Type Import (Alternative)
+You can also import generated types directly:
+
+```typescript
+import { defineQuery } from "groq";
+// Next.js re-exports defineQuery for convenience:
+// import { defineQuery } from "next-sanity";
+
+const AUTHOR_QUERY = defineQuery(`*[_type == "author" && slug.current == $slug][0]{ name, bio }`);
+
+import type { AUTHOR_QUERYResult } from "@/sanity.types";
+
+export default function Author({ data }: { data: AUTHOR_QUERYResult }) {
+ return {data.name}
+}
+```
+
+### Required Fields
+Use `--enforce-required-fields` during extraction to translate `validation: rule => rule.required()` into non-optional types:
+
+```bash
+npx sanity schema extract --enforce-required-fields
+npx sanity typegen generate
+```
+
+> **Warning:** If you use draft previews, fields may still be `undefined` even with required validation, since drafts can be in an invalid state.
+
+### Type Utilities
+TypeGen provides utilities for working with complex types:
+
+```typescript
+import type { Get, FilterByType } from 'sanity'
+import type { Page, PageBuilder } from './sanity.types'
+
+// Extract deeply nested type (up to 20 levels)
+type HeroSection = Get
+
+// Filter specific types from unions using _type discriminator
+type HeroBlock = FilterByType
+```
+
+### Unique Query Names
+All queries must have unique variable names. Duplicate names across files will cause TypeGen to silently overwrite types. Use descriptive, scoped names:
+
+```typescript
+// Unique names
+const POSTS_INDEX_QUERY = defineQuery(`*[_type == "post"]{ title }`)
+const POST_DETAIL_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`)
+
+// Duplicate names will conflict
+const QUERY = defineQuery(`*[_type == "post"]`) // file-a.ts
+const QUERY = defineQuery(`*[_type == "author"]`) // file-b.ts — overwrites!
+```
+
+### Supported Query Formats
+Queries must be assigned to a variable using `groq` or `defineQuery`:
+
+```typescript
+// Works — groq template tag
+const query = groq`*[_type == "post"]`
+
+// Works — defineQuery
+const query = defineQuery(`*[_type == "post"]`)
+
+// Won't work — inline query
+await client.fetch(groq`*[_type == "post"]`)
+```
+
+### Supported File Types
+TypeGen parses queries from: `.ts`, `.tsx`, `.js`, `.jsx`, `.astro`, `.svelte`, `.vue`
+
+### tsconfig Requirements
+Ensure `sanity.types.ts` is included in your `tsconfig.json`'s `include` array. If your config restricts includes (e.g., `["src/**/*"]`) and the types file is at the project root, TypeScript won't pick up the generated types:
+
+```json
+{
+ "include": ["src/**/*", "sanity.types.ts"]
+}
+```
+
+### Skipping Individual Queries
+Add `@sanity-typegen-ignore` in a comment before a query to skip type generation:
+
+```typescript
+// @sanity-typegen-ignore
+const debugQuery = groq`*[_type == "debug"]`
+```
diff --git a/.agents/skills/sanity-best-practices/references/visual-editing.md b/.agents/skills/sanity-best-practices/references/visual-editing.md
new file mode 100644
index 000000000..1f7a9678e
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/visual-editing.md
@@ -0,0 +1,263 @@
+---
+title: "Sanity Visual Editing Rules"
+description: Comprehensive guide for Sanity Visual Editing, including Presentation Tool, Stega (Content Source Maps), and Overlays.
+---
+
+# Sanity Visual Editing Rules
+
+## 1. Concepts
+
+### Presentation Tool
+The Studio plugin (`sanity/presentation`) that renders your front-end application inside an iframe in the Studio. It enables the "Edit" overlay and bidirectional navigation.
+
+### Content Source Maps (Stega)
+Invisible characters embedded in strings that tell the Presentation Tool which field in which document the content comes from.
+- **Mechanism:** Sanity encodes document ID, field path, and dataset info into string values.
+- **Result:** Click-to-edit functionality in the preview.
+
+### Loaders
+Framework-agnostic or specific libraries that handle:
+1. Fetching data (production vs. preview).
+2. Subscribing to real-time updates (Live Content API).
+3. Encoding Stega strings (if not handled by the Content Lake automatically).
+
+## 2. The Golden Rule of Stega (Clean Data)
+
+When Visual Editing is enabled, string fields will contain invisible characters. You **MUST** clean them before using the value for logic.
+
+| Scenario | Clean? | Why |
+|----------|--------|-----|
+| Comparing strings (`if (x === 'y')`) | ✅ Yes | Stega breaks equality |
+| Using as object keys | ✅ Yes | Keys won't match |
+| Using as HTML IDs | ✅ Yes | Invalid characters |
+| Passing to third-party libraries | ✅ Yes | May validate input |
+| Rendering text (`{title}
`) | ❌ No | Breaks click-to-edit |
+| Passing to `` | ❌ No | Handles internally |
+| Passing to image helpers | ❌ No | Handles internally |
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export function Layout({ align }: { align: string }) {
+ // Good: Clean before comparison
+ const cleanAlign = stegaClean(align);
+ return
+}
+```
+
+## 3. Token Handling (Security)
+
+Store your read token in a dedicated file that throws if missing:
+
+```typescript
+// src/sanity/lib/token.ts
+export const token = process.env.SANITY_API_READ_TOKEN
+
+if (!token) {
+ throw new Error('Missing SANITY_API_READ_TOKEN')
+}
+```
+
+**Never** expose tokens in client bundles. Pass to `defineLive` for server/browser use only when Draft Mode is enabled.
+
+## 4. Setup: Presentation Tool
+
+**File:** `sanity.config.ts`
+
+```typescript
+import { defineConfig } from 'sanity'
+import { presentationTool } from 'sanity/presentation'
+import { resolve } from '@/sanity/presentation/resolve'
+
+export default defineConfig({
+ // ...
+ plugins: [
+ presentationTool({
+ resolve, // Document locations (see below)
+ previewUrl: {
+ previewMode: {
+ enable: '/api/draft-mode/enable',
+ },
+ },
+ }),
+ ],
+})
+```
+
+### Document Locations
+
+Show where documents appear in the front-end — enables quick navigation between Structure and Presentation tools.
+
+```typescript
+// src/sanity/presentation/resolve.ts
+import { defineLocations, PresentationPluginOptions } from 'sanity/presentation'
+
+export const resolve: PresentationPluginOptions['resolve'] = {
+ locations: {
+ post: defineLocations({
+ select: { title: 'title', slug: 'slug.current' },
+ resolve: (doc) => ({
+ locations: [
+ { title: doc?.title || 'Untitled', href: `/posts/${doc?.slug}` },
+ { title: 'Posts index', href: `/posts` },
+ ],
+ }),
+ }),
+ // Add more document types as needed
+ },
+}
+```
+
+## 5. Visual Editing Overlays
+
+Render `` in Draft Mode for click-to-edit overlays.
+
+**Next.js (App Router):**
+```typescript
+// layout.tsx
+import { VisualEditing } from 'next-sanity/visual-editing'
+import { draftMode } from 'next/headers'
+import { DisableDraftMode } from '@/components/disable-draft-mode'
+
+export default async function RootLayout({ children }) {
+ return (
+
+
+ {children}
+ {(await draftMode()).isEnabled && (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
+```
+
+### Disable Draft Mode Button
+
+Useful for content authors to exit preview and see published content:
+
+```typescript
+// src/components/disable-draft-mode.tsx
+'use client'
+import { useDraftModeEnvironment } from 'next-sanity/hooks'
+
+export function DisableDraftMode() {
+ const environment = useDraftModeEnvironment()
+ // Only show outside of Presentation Tool
+ if (environment !== 'live' && environment !== 'unknown') return null
+
+ return (
+
+ Disable Draft Mode
+
+ )
+}
+```
+
+**Remix/Svelte:** See framework-specific rules for `useLiveMode` and `enableVisualEditing` patterns.
+
+## 6. SEO & Metadata (Critical)
+
+**NEVER** allow Stega strings in `` tags (Title, Description, Canonical URLs). It destroys SEO rankings and looks broken in search results.
+
+- **Next.js:** Set `stega: false` in `generateMetadata`.
+- **General:** Explicitly clean fields used in `` or ``.
+
+```typescript
+// Next.js Example — disable stega at fetch level
+export async function generateMetadata({ params }) {
+ const { data } = await sanityFetch({
+ query: SEO_QUERY,
+ stega: false // Critical
+ })
+ return { title: data.title }
+}
+```
+
+**Alternative:** If you can't disable stega at the fetch level, clean explicitly:
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export async function generateMetadata({ params }) {
+ const { data } = await sanityFetch({ query: PAGE_QUERY })
+ return {
+ title: stegaClean(data.title),
+ description: stegaClean(data.description),
+ openGraph: { url: stegaClean(data.canonicalUrl) }
+ }
+}
+```
+
+## 7. Drag-and-Drop Reordering (Advanced)
+
+For arrays (e.g., "Related Posts"), enable drag-and-drop in the preview using `data-sanity` attributes and `useOptimistic`:
+
+```typescript
+import { createDataAttribute } from 'next-sanity'
+import { useOptimistic } from 'next-sanity/hooks'
+
+// Add data-sanity to array container
+
+ {items.map((item) => (
+ -
+ {item.title}
+
+ ))}
+
+```
+
+**Key requirements:**
+- Query must include `_key` for array items
+- Use `useOptimistic` hook for instant UI updates during mutations
+
+## 8. Optimistic Updates for Faster Editing
+
+By default, editing a field in the Presentation Tool triggers a full page re-render. For pages with many components, this can feel sluggish. **Presentation queries** solve this by fetching only the specific block being edited.
+
+### The Concept
+
+Instead of:
+1. User edits a field -> Full page query re-runs -> All components re-render
+
+You get:
+1. User edits a field -> Block-specific query runs -> Only that component re-renders
+
+### How It Works
+
+1. **Create a targeted query** that fetches just the block data using `_key`:
+
+```groq
+*[_id == $documentId][0]{
+ "heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
+ title, subtitle, image
+ }
+}
+```
+
+2. **Use a presentation query hook** in your component (e.g., `usePresentationQuery` in Next.js)
+
+3. **Fall back to initial props** when not in presentation mode
+
+This pattern works for both Page Builder blocks (`pageBuilder[]`) and Portable Text blocks (`body[]`).
+
+**See framework-specific rules for implementation:**
+- Next.js: `nextjs.md` (Section 9)
+- Page Builder: `page-builder.md` (Section 5)
+- Portable Text: `portable-text.md` (Section 7)
+
+## 9. Framework Specifics
+
+| Framework | Loader Package | Key Components |
+| :--- | :--- | :--- |
+| **Next.js** | `next-sanity` | ``, `defineLive`, `usePresentationQuery` |
+| **Remix** | `@sanity/react-loader` | `createQueryStore`, `useLiveMode`, `enableVisualEditing` |
+| **Svelte** | `@sanity/svelte-loader` | `createRequestHandler`, `useLiveMode`, `enableVisualEditing` |
+| **Nuxt** | `@nuxtjs/sanity` | Automatic via module config (`visualEditing: {}`) |
+| **Astro** | `@sanity/astro` | `sanity({ useCdn: false, stega: true })` |
diff --git a/.agents/skills/sanity-live-cache-components/SKILL.md b/.agents/skills/sanity-live-cache-components/SKILL.md
new file mode 100644
index 000000000..c6075b783
--- /dev/null
+++ b/.agents/skills/sanity-live-cache-components/SKILL.md
@@ -0,0 +1,168 @@
+---
+name: sanity-live-cache-components
+description: Integrates Sanity Live with Next.js Cache Components in next-sanity v13+ apps. Sets up sanityFetch, , Visual Editing, Presentation Tool, draft mode handling, and the three-layer (Page/Dynamic/Cached) component pattern with explicit perspective/stega prop-drilling. Use when configuring or migrating a Next.js app to cacheComponents with Sanity, when adding sanityFetch, when wiring /, or when refactoring components that hardcode perspective/stega.
+---
+
+# Sanity Live + Cache Components
+
+Wires `next-sanity` into a Next.js 16+ app with `cacheComponents: true`. Data is fetched with `sanityFetch` (which calls `cacheTag`/`cacheLife` internally), and `` in the root layout revalidates cached content over an EventSource connection to Sanity Content Lake. Visual Editing and Presentation Tool are fully supported when draft mode is enabled.
+
+Read the relevant guide in `node_modules/next/dist/docs/` (when available) before writing code. If a guide conflicts with this skill, follow this skill.
+
+This skill assumes familiarity with the `next-cache-components` skill — it covers `'use cache'`, `cacheLife`, `cacheTag`, and the cookies/headers/params rule. The only Sanity-relevant exception: `await draftMode()` is allowed inside `'use cache'` (Next.js bypasses caching when draft mode is enabled — see [the `use cache` reference](https://nextjs.org/docs/app/api-reference/directives/use-cache#draft-mode)).
+
+## Prerequisites
+
+- Next.js 16.2+ installed in the project (check `package.json` or run `pnpm list next` / `npm ls next` — don't use `pnpm view next version`, that reports the registry's latest, not what's installed).
+- `AGENTS.md` exists, or [follow the guide](https://nextjs.org/docs/app/guides/ai-agents#existing-projects).
+- These environment variables are set:
+ - `NEXT_PUBLIC_SANITY_PROJECT_ID`
+ - `NEXT_PUBLIC_SANITY_DATASET`
+ - `SANITY_API_READ_TOKEN`
+- Embedded Sanity Studio configuration (`sanity.config.ts`, `sanity.cli.ts`, anything under `sanity/`) needs no changes — this skill only touches the Next.js app surface.
+
+## Reference files
+
+| File | When to read |
+| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| [reference/live-helpers.md](reference/live-helpers.md) | Full `client.ts` / `live.ts`, `sanityFetch*` and `getDynamicFetchOptions` details |
+| [reference/three-layer-pattern.md](reference/three-layer-pattern.md) | The Page → Dynamic → Cached pattern for `page.tsx`, including the `searchParams` variant |
+| [reference/layouts.md](reference/layouts.md) | Non-blocking data fetching inside `layout.tsx` with a shared `'use cache'` helper |
+| [reference/dynamic-segments.md](reference/dynamic-segments.md) | High-performance `[slug]` routes: `loading.tsx` + partial `generateStaticParams`, or non-blocking dynamic `params` in a layout |
+
+---
+
+## 1. Install `next-sanity@^13`
+
+```bash
+npm install next-sanity@^13 --save-exact
+```
+
+### Migrating an existing Sanity Live setup
+
+If the app is already using `defineLive`, this skill is a refactor, not a rewrite. The 5-step sequence below still applies, but watch for these specific differences:
+
+- **Don't overwrite `client.ts` or `live.ts`** if they exist. Append missing options. Preserve any existing `token` and `stega.*` settings — see [reference/live-helpers.md](reference/live-helpers.md).
+- **Search the codebase for hardcoded `perspective: 'published'` and `stega: false`** in `sanityFetch` callsites and refactor them to source `perspective`/`stega` via `getDynamicFetchOptions` and the three-layer pattern.
+- **Search for `sanityFetch` calls inside `generateStaticParams`** → swap for `sanityFetchStaticParams`.
+- **Search for `sanityFetch` calls inside `generateMetadata` / `sitemap.ts` / `opengraph-image.tsx` / etc.** → swap for `sanityFetchMetadata`.
+- **Search for `sanityFetch` calls directly inside a `'use server'` function** → split into a separate `'use cache'` helper.
+- **Verify there is exactly one `` and one `` in the tree.** Multiple renders are undefined behavior.
+
+The "Anti-patterns to grep for" section at the bottom of this file lists the search patterns.
+
+---
+
+## 2. Configure `next.config.ts`
+
+Enable `cacheComponents` and set `cacheLife.default` to `sanity` so default revalidation is 1 year (instead of 15 minutes). `sanityFetch` is optimized for on-demand revalidation and doesn't need time-based revalidation.
+
+```ts
+// next.config.ts
+import type {NextConfig} from 'next'
+import {sanity} from 'next-sanity/live/cache-life'
+
+const nextConfig: NextConfig = {
+ cacheComponents: true,
+ cacheLife: {default: sanity},
+}
+
+export default nextConfig
+```
+
+---
+
+## 3. Configure `defineLive` and export helpers
+
+Create `src/sanity/lib/client.ts` and `src/sanity/lib/live.ts`. The minimal `defineLive` call:
+
+```ts
+// src/sanity/lib/live.ts (excerpt)
+export const {SanityLive, sanityFetch} = defineLive({
+ client,
+ serverToken: token,
+ browserToken: token,
+ strict: true,
+})
+```
+
+Full file contents (including `client.ts`, `getDynamicFetchOptions`, `sanityFetchMetadata`, `sanityFetchStaticParams`) and per-helper guidance: [reference/live-helpers.md](reference/live-helpers.md).
+
+The helpers exported from `live.ts`:
+
+| Helper | Used in |
+| ------------------------- | ---------------------------------------------------------------------------------------------- |
+| `sanityFetch` | `'use cache'` components rendered from `page.tsx` / `layout.tsx` |
+| `sanityFetchMetadata` | `generateMetadata`, `generateViewport`, `sitemap.ts`, `robots.ts`, `opengraph-image.tsx`, etc. |
+| `sanityFetchStaticParams` | `generateStaticParams` only |
+| `getDynamicFetchOptions` | Resolving `perspective`/`stega` outside any `'use cache'` boundary |
+| `SanityLive` | Rendered once in a root layout |
+
+---
+
+## 4. Render `` in a root layout
+
+`` and `` both belong in a `layout.tsx`, never a `page.tsx`. Both must be rendered at most once across the whole tree — duplicate renders are undefined behavior.
+
+- `includeDrafts` is **required** when `defineLive` is configured with `strict: true` (the recommended setup). TypeScript will surface the error if it's missing; pass `includeDrafts={isDraftMode}` so live revalidation includes drafts only in draft mode.
+- Preserve any existing optional callback props on `` when migrating: `onError`, `onWelcome`, `onReconnect`. They are commonly wired to a toast/notification helper and silently dropping them regresses UX.
+
+```tsx
+// src/app/layout.tsx
+import {SanityLive} from '@/sanity/lib/live'
+import {VisualEditing} from 'next-sanity/visual-editing'
+import {draftMode} from 'next/headers'
+
+export default async function RootLayout({children}: LayoutProps<'/'>) {
+ const {isEnabled: isDraftMode} = await draftMode()
+ return (
+
+
+ {children}
+
+ {isDraftMode && }
+
+
+ )
+}
+```
+
+### With an embedded Sanity Studio
+
+If a route mounts `NextStudio` from `next-sanity/studio` (e.g. `app/studio/[[...index]]/page.tsx`), `` must live in a layout the embedded studio doesn't share. Use [route groups](https://nextjs.org/docs/app/api-reference/file-conventions/route-groups): put `` in `src/app/(website)/layout.tsx` and keep the rest of the app under `src/app/(website)`.
+
+---
+
+## 5. Apply the three-layer pattern to pages and layouts
+
+Every route that should be statically prerendered uses the same shape:
+
+```text
+Page/Layout (Layer 1: draftMode branch)
+ ├── NOT draft mode → (no Suspense)
+ └── draft mode →
+ (Layer 2: awaits dynamic APIs)
+ └── (Layer 3: 'use cache')
+```
+
+**Critical rule**: Only Layer 3 carries `'use cache'`. The top-level `Page` / `Layout` must **not** have `'use cache'` — it awaits `params`, `searchParams`, or `cookies()` (via `getDynamicFetchOptions`), and those dynamic APIs are forbidden inside `'use cache'`. Layer 3 carrying `'use cache'` is enough for the whole route to prerender into the static shell. Adding `'use cache'` to the top-level function is the most common failure mode — TypeScript and the runtime will both complain.
+
+Pick the right reference for the file you're editing:
+
+- **`page.tsx`** with static or `generateStaticParams`-backed params → [reference/three-layer-pattern.md](reference/three-layer-pattern.md).
+- **`page.tsx`** that uses `searchParams` or other dynamic APIs → the `searchParams` variant in [reference/three-layer-pattern.md](reference/three-layer-pattern.md).
+- **`layout.tsx`** that fetches its own data → [reference/layouts.md](reference/layouts.md).
+- **Dynamic `[slug]` route** that needs the `loading.tsx` + partial `generateStaticParams` optimization, or a layout that needs non-blocking `params` → [reference/dynamic-segments.md](reference/dynamic-segments.md).
+
+---
+
+## Anti-patterns to grep for
+
+When auditing an app, search for these and refactor:
+
+- `perspective: 'published'` and `stega: false` hardcoded together in a `sanityFetch` call → use the three-layer pattern, source `perspective`/`stega` via `getDynamicFetchOptions`.
+- `sanityFetch(` directly inside a function whose body begins with `'use server'` → split into a separate `'use cache'` helper.
+- `sanityFetch(` inside `generateStaticParams` → swap for `sanityFetchStaticParams`.
+- `sanityFetch(` inside `generateMetadata` / `generateViewport` / `sitemap.ts` / `robots.ts` / `opengraph-image.tsx` etc. → swap for `sanityFetchMetadata` and resolve `perspective` via `getDynamicFetchOptions`.
+- `await draftMode()` immediately followed by `await getDynamicFetchOptions()` at the top of a `page.tsx` or `layout.tsx` without a sibling `loading.tsx` → move those dynamic-API calls into a child component wrapped in `` so the static shell can prerender.
+- More than one `` or `` rendered in the tree → consolidate to a single render in the right layout.
diff --git a/.agents/skills/sanity-live-cache-components/reference/dynamic-segments.md b/.agents/skills/sanity-live-cache-components/reference/dynamic-segments.md
new file mode 100644
index 000000000..09ab6aa02
--- /dev/null
+++ b/.agents/skills/sanity-live-cache-components/reference/dynamic-segments.md
@@ -0,0 +1,114 @@
+# High-performance dynamic segments
+
+[Dynamic routes](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes) should always implement `generateStaticParams`, even if only a subset of pages — see [the Cache Components note on dynamic routes](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes#with-cache-components). Whether to use `loading.tsx` or `` for fallback UI depends on the use case — see [the streaming guide](https://nextjs.org/docs/app/guides/streaming#when-to-use-loadingjs-vs-suspense).
+
+## Contents
+
+- [Case 1: `page.tsx` with `loading.tsx` + partial `generateStaticParams`](#case-1-pagetsx-with-loadingtsx--partial-generatestaticparams)
+- [Case 2: `layout.tsx` with non-blocking dynamic `params`](#case-2-layouttsx-with-non-blocking-dynamic-params)
+
+## Case 1: `page.tsx` with `loading.tsx` + partial `generateStaticParams`
+
+`generateStaticParams` returns only the 100 most recently updated pages. A sibling `loading.tsx` renders fallback UI, so `page.tsx` itself can skip the `` wrapper. The same fallback UI is reused in draft mode.
+
+This scales to thousands of pages without ballooning `next build` and without compromising UX in production:
+
+- Prerendered pages load instantly.
+- Pages not prerendered start rendering on `` hover (or when scrolled into view), so on click:
+ - If prerendering finished in time → serves instantly, no loading state.
+ - If not → instantly shows the cached `loading.tsx` fallback.
+
+Add a sibling `src/app/[slug]/loading.tsx` that renders the same skeleton you would otherwise pass to ``. Keep it cheap and free of layout shift:
+
+```tsx
+// src/app/[slug]/loading.tsx
+export default function Loading() {
+ return (
+
+ Loading…
+
+ )
+}
+```
+
+```tsx
+// src/app/[slug]/page.tsx
+import {
+ getDynamicFetchOptions,
+ sanityFetch,
+ sanityFetchStaticParams,
+ type DynamicFetchOptions,
+} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+
+export async function generateStaticParams() {
+ const pageSlugsQuery = defineQuery(
+ `*[_type == "page" && defined(slug.current)] | order(_updatedAt desc) [0...100]{"slug": slug.current}`,
+ )
+ const {data} = await sanityFetchStaticParams({query: pageSlugsQuery})
+ return data
+}
+
+// With sibling `loading.tsx`, skip the `` + `DynamicPage` indirection: await `params`
+// and `getDynamicFetchOptions` directly inside `Page`.
+export default async function Page({params}: PageProps<'/[slug]'>) {
+ const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
+ return
+}
+async function CachedPage({
+ slug,
+ perspective,
+ stega,
+}: Awaited['params']> & DynamicFetchOptions) {
+ 'use cache'
+ const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
+ const {data} = await sanityFetch({
+ query: pageQuery,
+ params: {slug},
+ perspective,
+ stega,
+ })
+ return {/* use `data` to render stuff */}
+}
+```
+
+## Case 2: `layout.tsx` with non-blocking dynamic `params`
+
+A `layout.tsx` can't use `loading.tsx` for fallback UI — [it's one level higher in the hierarchy](https://nextjs.org/docs/app/getting-started/project-structure#component-hierarchy). To fetch data that depends on dynamic `params` without blocking `children` from streaming, pass the unawaited `params` promise into a `` boundary and await it inside.
+
+```tsx
+// src/app/(website)/[slug]/layout.tsx
+
+import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+import {Suspense} from 'react'
+
+export default function WebsiteLayout({children, params}: LayoutProps<'/[slug]'>) {
+ return (
+ <>
+ {children}
+ {/* The footer renders below the fold, no fallback needed */}
+
+
+
+ >
+ )
+}
+async function DynamicFooter({params}: Pick, 'params'>) {
+ const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
+ return
+}
+async function Footer({
+ slug,
+ perspective,
+ stega,
+}: Awaited['params']> & DynamicFetchOptions) {
+ 'use cache'
+ const footerQuery = defineQuery(`*[_type == "footer" && slug.current == $slug][0]`)
+ const {data} = await sanityFetch({query: footerQuery, params: {slug}, perspective, stega})
+ return
+}
+```
diff --git a/.agents/skills/sanity-live-cache-components/reference/layouts.md b/.agents/skills/sanity-live-cache-components/reference/layouts.md
new file mode 100644
index 000000000..ff1549520
--- /dev/null
+++ b/.agents/skills/sanity-live-cache-components/reference/layouts.md
@@ -0,0 +1,163 @@
+# Non-blocking layout patterns
+
+When `sanityFetch` runs inside a `layout.tsx`, the goal is to keep `children` streaming and keep the static shell as large as possible.
+
+## Contents
+
+- [Rules](#rules)
+- [Pattern: shared `'use cache'` helper per draft/published branch](#pattern-shared-use-cache-helper-per-draftpublished-branch)
+- [Anti-pattern: wrapping `children` in a single cached layout](#anti-pattern-wrapping-children-in-a-single-cached-layout)
+- [Simpler example: a single `
+ ) : (
+
+ )}
+ {children}
+ {isDraftMode ? (
+
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+async function DynamicNavbar() {
+ const {perspective, stega} = await getDynamicFetchOptions()
+ return
+}
+async function CachedNavbar({perspective, stega}: DynamicFetchOptions) {
+ 'use cache'
+ const data = await fetchSettings({perspective, stega})
+ return
+}
+
+async function DynamicFooter() {
+ const {perspective, stega} = await getDynamicFetchOptions()
+ return
+}
+async function CachedFooter({perspective, stega}: DynamicFetchOptions) {
+ 'use cache'
+ const data = await fetchSettings({perspective, stega})
+ return
+}
+```
+
+## Anti-pattern: wrapping `children` in a single cached layout
+
+This blocks `children` on the layout's data fetch and prevents the page itself from streaming in independently.
+
+```tsx
+// src/app/(website)/layout.tsx
+export default async function WebsiteLayout({children}: LayoutProps<'/'>) {
+ const {isEnabled: isDraftMode} = await draftMode()
+ if (isDraftMode) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+ {children}
+
+ )
+}
+async function CachedWebsiteLayout({
+ children,
+ perspective,
+ stega,
+}: {children: ReactNode} & DynamicFetchOptions) {
+ 'use cache'
+ const settingsQuery = defineQuery(`*[_type == "settings"][0]`)
+ const {data} = await sanityFetch({query: settingsQuery, perspective, stega})
+
+ return (
+ <>
+
+ {children}
+
+ >
+ )
+}
+```
+
+## Simpler example: a single `
+ ) : (
+
+ )}
+ >
+ )
+}
+async function DynamicFooter() {
+ const {perspective, stega} = await getDynamicFetchOptions()
+ return
+}
+async function Footer({perspective, stega}: DynamicFetchOptions) {
+ 'use cache'
+ const footerQuery = defineQuery(`*[_type == "footer"][0]`)
+ const {data} = await sanityFetch({query: footerQuery, perspective, stega})
+ return
+}
+function FooterFallback() {
+ return (
+
+ )
+}
+```
+
+The non-draft `` is part of the static shell, so the whole layout is cached and revalidates only when content used by `sanityFetch` changes. In draft mode the layout still renders immediately from its static shell while `` streams in.
diff --git a/.agents/skills/sanity-live-cache-components/reference/live-helpers.md b/.agents/skills/sanity-live-cache-components/reference/live-helpers.md
new file mode 100644
index 000000000..4f7d63307
--- /dev/null
+++ b/.agents/skills/sanity-live-cache-components/reference/live-helpers.md
@@ -0,0 +1,220 @@
+# Live helpers: `client.ts` and `live.ts`
+
+## Contents
+
+- [`client.ts`](#clientts)
+- [`live.ts`](#livets)
+- [`sanityFetch`](#sanityfetch)
+- [`sanityFetchMetadata`](#sanityfetchmetadata)
+- [`getDynamicFetchOptions`](#getdynamicfetchoptions)
+- [`sanityFetchStaticParams`](#sanityfetchstaticparams)
+- [Anti-patterns to grep for](#anti-patterns-to-grep-for)
+
+## `client.ts`
+
+Projects typically have a `src/sanity/lib/client.ts` that exports a `createClient` instance.
+
+**If no `client.ts` exists yet**, use this shape as a starting point:
+
+```ts
+// src/sanity/lib/client.ts
+import {createClient} from 'next-sanity'
+
+export const client = createClient({
+ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
+ dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
+ useCdn: true,
+ apiVersion: '2026-05-19',
+ perspective: 'published',
+ stega: {studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333'},
+})
+```
+
+**If `client.ts` already exists**, leave its structure alone. Templates often centralize env-var reads in a separate `sanity/lib/api.ts` with an `assertValue` helper — keep that. Append only what's missing.
+
+- Use a modern `apiVersion` (e.g. today's date as a hardcoded string).
+- `stega.studioUrl` enables stega encoding. It can be a relative string when an embedded Studio is mounted via `NextStudio` from `next-sanity/studio`, otherwise an absolute URL (typically env-driven).
+- Changing `apiVersion` or removing existing `stega.*` options can break callers.
+- Never remove an existing `token` from `createClient`. Private datasets require a client token even for published-content fetches.
+
+## `live.ts`
+
+Create `src/sanity/lib/live.ts` alongside `client.ts`. If it already exists, append only what's missing.
+
+`SANITY_API_READ_TOKEN` must never reach the client bundle. If the project already keeps it in a dedicated server-only module (commonly `src/sanity/lib/token.ts` with `import 'server-only'` at the top), import the token from there instead of inlining the `process.env` read. The example below inlines it for brevity — swap in the existing module if there is one.
+
+```ts
+// src/sanity/lib/live.ts
+import {type QueryParams} from 'next-sanity'
+import {defineLive, resolvePerspectiveFromCookies, type LivePerspective} from 'next-sanity/live'
+import {cookies, draftMode} from 'next/headers'
+import {client} from './client'
+
+const token = process.env.SANITY_API_READ_TOKEN
+if (!token) {
+ throw new Error('Missing SANITY_API_READ_TOKEN')
+}
+
+export const {SanityLive, sanityFetch} = defineLive({
+ client,
+ serverToken: token,
+ browserToken: token,
+ strict: true,
+})
+
+export interface DynamicFetchOptions {
+ perspective: LivePerspective
+ stega: boolean
+}
+export async function getDynamicFetchOptions(): Promise {
+ const {isEnabled: isDraftMode} = await draftMode()
+ if (!isDraftMode) {
+ return {perspective: 'published', stega: false}
+ }
+
+ const jar = await cookies()
+ const perspective = await resolvePerspectiveFromCookies({cookies: jar})
+ return {perspective: perspective ?? 'drafts', stega: true}
+}
+
+// For usage within `generateStaticParams`
+export async function sanityFetchStaticParams({
+ query,
+ params = {},
+}: {
+ query: QueryString
+ params?: QueryParams
+}) {
+ 'use cache'
+ const {data} = await sanityFetch({query, params, perspective: 'published', stega: false})
+ return {data}
+}
+
+// For usage within `generateMetadata` and `generateViewport`
+export async function sanityFetchMetadata({
+ query,
+ params = {},
+ perspective,
+}: {
+ query: QueryString
+ params?: QueryParams
+ perspective: LivePerspective
+}) {
+ 'use cache'
+ const {data} = await sanityFetch({query, params, perspective, stega: false})
+ return {data}
+}
+```
+
+## `sanityFetch`
+
+For fetching data in React Server Components that have a `'use cache'` directive and are rendered (directly or transitively) from a `page.tsx` or `layout.tsx`.
+
+- `perspective` switches between published, drafts, and specific Sanity Content Releases.
+- `stega: true` (combined with `stega.studioUrl` in `createClient` and `` in the root layout) renders click-to-edit overlays.
+- `getDynamicFetchOptions` resolves `perspective` from the `sanity-preview-perspective` cookie, which `` manages when the app is rendered inside Presentation Tool's preview iframe.
+
+The async function that calls `sanityFetch` must carry `'use cache'` or `'use cache: remote'`, and must take `perspective` and `stega` as props. Never hardcode them.
+
+Pattern:
+
+```tsx
+import {sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+
+async function CachedComponent({slug, perspective, stega}: {slug: string} & DynamicFetchOptions) {
+ 'use cache'
+ const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
+ const {data} = await sanityFetch({query: pageQuery, params: {slug}, perspective, stega})
+}
+```
+
+Anti-pattern (hardcoded options break Visual Editing and content-release previewing):
+
+```tsx
+async function CachedComponent({slug}: {slug: string}) {
+ 'use cache'
+ const {data} = await sanityFetch({
+ query: pageQuery,
+ params: {slug},
+ perspective: 'published', // hardcoded
+ stega: false, // hardcoded
+ })
+}
+```
+
+### `sanityFetch` inside server actions
+
+`'use server'` boundaries cannot accept `perspective`/`stega` as props (server action inputs are untrusted). Resolve them inside the `'use server'` function and forward them to a separate `'use cache'` boundary:
+
+```tsx
+import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+
+async function fetchMore({page, perspective, stega}: {page: string} & DynamicFetchOptions) {
+ 'use cache'
+ const pagesQuery = defineQuery(`*[_type == "page"][0...$page]`)
+ const {data} = await sanityFetch({query: pagesQuery, params: {page}, perspective, stega})
+ return data
+}
+async function renderMore({page}: {page: string}) {
+ 'use server'
+ const {perspective, stega} = await getDynamicFetchOptions()
+ const data = await fetchMore({page, perspective, stega})
+}
+```
+
+Anti-patterns:
+
+- Hardcoding `perspective`/`stega` in the `'use cache'` helper.
+- Calling `sanityFetch` directly inside `'use server'` — it bypasses caching entirely.
+
+### `sanityFetch` inside `route.ts`
+
+Hardcode `stega: false` and resolve only `perspective`. Route handlers don't render a DOM next to ``, so stega encoding only inflates the payload (and can cause downstream errors).
+
+## `sanityFetchMetadata`
+
+For fetching data inside `generateMetadata`, `generateSitemaps`, `generateViewport`, `generateImageMetadata`, and the file-based metadata routes (`icon.tsx`, `apple-icon.tsx`, `manifest.ts`, `opengraph-image.tsx`, `twitter-image.tsx`, `robots.ts`, `sitemap.ts`).
+
+It's `sanityFetch` without `stega` (never wanted in these contexts) and without requiring `'use cache'` at the callsite — the helper already provides it.
+
+Presentation Tool can open an app in a standalone preview window, so the correct content release must still be reflected in `` and friends. Always resolve `perspective`:
+
+```ts
+import {getDynamicFetchOptions, sanityFetchMetadata} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+
+export async function generateMetadata({params}: PageProps<'/[slug]'>) {
+ const [{slug}, {perspective}] = await Promise.all([params, getDynamicFetchOptions()])
+ const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
+ const {data} = await sanityFetchMetadata({query: pageQuery, params: {slug}, perspective})
+}
+```
+
+Anti-pattern: hardcoding `perspective: 'published'` — content-release previewing won't work.
+
+## `getDynamicFetchOptions`
+
+Resolves `perspective` and `stega` outside the `'use cache'` boundary so they can be passed in as plain props. Calls `cookies()`, which is a dynamic API, so the call must live inside a `` boundary (or a route with a sibling `loading.tsx`) so it doesn't block the static shell from streaming.
+
+Avoid calling `getDynamicFetchOptions` in the top-level body of a `layout.tsx` or `page.tsx` that should remain part of the static shell. The exception is routes that intentionally use a sibling `loading.tsx` for fallback UI (see [dynamic-segments.md](dynamic-segments.md)) — there the page can await `getDynamicFetchOptions` directly because `loading.tsx` provides the streaming fallback.
+
+When Cache Components are enabled, `` boundaries determine the static shell. For fully prerendered routes, render the Suspense tree only when in draft mode — see [three-layer-pattern.md](three-layer-pattern.md).
+
+## `sanityFetchStaticParams`
+
+Used inside `generateStaticParams`. `stega` is never wanted (the data feeds route params), and `perspective` cookies aren't available at build time anyway, so both are hardcoded.
+
+- Never call `sanityFetch` inside `generateStaticParams` — always use `sanityFetchStaticParams`.
+- Never call `sanityFetchStaticParams` outside `generateStaticParams`.
+
+## Anti-patterns to grep for
+
+When migrating an existing app, these are the strings to search for and refactor:
+
+- `perspective: 'published'` and `stega: false` hardcoded together in a `sanityFetch` call → replace with `perspective` and `stega` props sourced from `getDynamicFetchOptions` via the three-layer pattern.
+- `sanityFetch(` directly inside a function whose body starts with `'use server'` → split into a separate `'use cache'` helper and forward `perspective`/`stega` as props.
+- `sanityFetch(` inside `generateStaticParams` → swap for `sanityFetchStaticParams`.
+- `sanityFetch(` inside `generateMetadata` / `generateViewport` / `sitemap.ts` / `robots.ts` / `opengraph-image.tsx` etc. → swap for `sanityFetchMetadata` and resolve `perspective` via `getDynamicFetchOptions`.
+- `await draftMode()` immediately followed by `await getDynamicFetchOptions()` at the top of a `page.tsx` or `layout.tsx` without a sibling `loading.tsx` → move the dynamic-API calls into a child component wrapped in `` so the static shell can prerender.
diff --git a/.agents/skills/sanity-live-cache-components/reference/three-layer-pattern.md b/.agents/skills/sanity-live-cache-components/reference/three-layer-pattern.md
new file mode 100644
index 000000000..8f5b34205
--- /dev/null
+++ b/.agents/skills/sanity-live-cache-components/reference/three-layer-pattern.md
@@ -0,0 +1,146 @@
+# Three-layer component pattern
+
+The core architecture for every route that can be fully statically prerendered and cached.
+
+## Contents
+
+- [Structure](#structure)
+- [`generateStaticParams` for dynamic routes](#generatestaticparams-for-dynamic-routes)
+- [Layer 1: Page component](#layer-1-page-component)
+- [Layer 2: Dynamic component](#layer-2-dynamic-component)
+- [Layer 3: Cached component](#layer-3-cached-component)
+- [`searchParams` and other dynamic APIs](#searchparams-and-other-dynamic-apis)
+
+## Structure
+
+```text
+Page/Layout (Layer 1)
+ ├── NOT draft mode → (no Suspense)
+ └── draft mode →
+ (Layer 2)
+ └── (Layer 3)
+```
+
+## `generateStaticParams` for dynamic routes
+
+The examples below use `/[slug]/page.tsx`, which needs:
+
+```tsx
+// src/app/[slug]/page.tsx
+import {sanityFetchStaticParams} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+
+export async function generateStaticParams() {
+ const pageSlugsQuery = defineQuery(
+ `*[_type == "page" && defined(slug.current)]{"slug": slug.current}`,
+ )
+ const {data} = await sanityFetchStaticParams({query: pageSlugsQuery})
+ return data
+}
+```
+
+For `/layout.tsx` or `/page.tsx` (no params), skip the `params` handling.
+
+## Layer 1: Page component
+
+Calls `draftMode()` and branches:
+
+```tsx
+// src/app/[slug]/page.tsx (continued)
+import {draftMode} from 'next/headers'
+import {Suspense} from 'react'
+
+export default async function Page({params}: PageProps<'/[slug]'>) {
+ const {isEnabled: isDraftMode} = await draftMode()
+ if (isDraftMode) {
+ return (
+ }>
+ ` so the Suspense boundary works
+ params={params}
+ />
+
+ )
+ }
+ const {slug} = await params
+ return
+}
+```
+
+Notes:
+
+- `Page` does **not** have a `'use cache'` directive. `draftMode()` is allowed inside `'use cache'`, but `Page` also `awaits` `params` (and may call `getDynamicFetchOptions()`, which reads `cookies()`), and those dynamic APIs are not allowed inside `'use cache'`. It's enough for `` (Layer 3) to carry `'use cache'` for `Page` to be prerendered as part of the static shell.
+- Requires `generateStaticParams` if `params` is used as input to `sanityFetch`.
+- Not in draft mode → no `` boundary, maximizes the static shell.
+- In draft mode → `` inside `` will suspend twice:
+ 1. when `` awaits `getDynamicFetchOptions()`
+ 2. when `` awaits `sanityFetch` with the resolved `perspective`/`stega`
+
+ A good fallback skeleton that doesn't cause layout shift is highly recommended.
+
+## Layer 2: Dynamic component
+
+Resolves `params`, `cookies()`, and `headers()` outside the cache boundary and passes plain props in:
+
+```tsx
+// src/app/[slug]/page.tsx (continued)
+import {getDynamicFetchOptions} from '@/sanity/lib/live'
+
+async function DynamicPage({params}: Pick, 'params'>) {
+ const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
+
+ return
+}
+```
+
+`draftMode()` is the only dynamic API allowed inside `'use cache'`, but in this pattern it isn't needed in Layer 3 because `perspective` and `stega` already encode the draft state.
+
+## Layer 3: Cached component
+
+Has `'use cache'` and only receives plain, serializable props:
+
+```tsx
+// src/app/[slug]/page.tsx (continued)
+import {sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
+import {defineQuery} from 'next-sanity'
+
+async function CachedPage({
+ slug,
+ perspective,
+ stega,
+}: Awaited['params']> & DynamicFetchOptions) {
+ 'use cache'
+ const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
+ const {data} = await sanityFetch({
+ query: pageQuery,
+ params: {slug},
+ perspective,
+ stega,
+ })
+ return {/* use `data` to render stuff */}
+}
+```
+
+## `searchParams` and other dynamic APIs
+
+If `searchParams` or other dynamic APIs are inputs to `sanityFetch` (or `params` is used without `generateStaticParams` or a `loading.tsx`), always render the `` tree and **stop branching on `draftMode`**. See [the streaming guide](https://nextjs.org/docs/app/guides/streaming#when-to-use-loadingjs-vs-suspense) for picking between `loading.tsx` and ``.
+
+```tsx
+// src/app/[slug]/page.tsx (continued)
+import {Suspense} from 'react'
+
+// Do not export an async function here, to avoid accidentally blocking render while awaiting a dynamic API
+export default function Page({params}: PageProps<'/[slug]'>) {
+ return (
+ }
+ >
+ ` so the Suspense boundary works
+ params={params}
+ />
+
+ )
+}
+```
diff --git a/.agents/skills/seo-aeo-best-practices/SKILL.md b/.agents/skills/seo-aeo-best-practices/SKILL.md
new file mode 100644
index 000000000..ade2bb51e
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/SKILL.md
@@ -0,0 +1,37 @@
+---
+name: seo-aeo-best-practices
+description: SEO and AEO best practices for metadata, Open Graph, sitemaps, robots.txt, hreflang, JSON-LD structured data, EEAT, and content optimized for search engines and AI answer surfaces. Use this skill when implementing page SEO, technical SEO, schema markup, international SEO, AI-overview readiness, or improving content for Google, ChatGPT, Perplexity, and similar assistants.
+---
+
+# SEO & AEO Best Practices
+
+Principles for optimizing content for both traditional search engines (SEO) and AI-powered answer engines (AEO). Includes Google's EEAT guidelines and structured data implementation.
+
+## When to Apply
+
+Reference these guidelines when:
+- Implementing metadata and Open Graph tags
+- Creating sitemaps and robots.txt
+- Adding JSON-LD structured data
+- Optimizing content for featured snippets
+- Preparing content for AI assistants (ChatGPT, Perplexity, etc.)
+- Evaluating content quality using EEAT principles
+
+## Core Concepts
+
+### SEO (Search Engine Optimization)
+Optimizing content to rank well in traditional search results (Google, Bing).
+
+### AEO (Answer Engine Optimization)
+Optimizing content to be selected as authoritative answers by AI systems.
+
+### EEAT (Experience, Expertise, Authoritativeness, Trustworthiness)
+Google's framework for evaluating content quality.
+
+## References
+
+Start with the one reference that matches the task, such as technical SEO, structured data, EEAT, or AI-answer readiness. See `references/` for detailed guidance:
+- `references/eeat-principles.md` — EEAT implementation and author schema
+- `references/structured-data.md` — JSON-LD patterns (Article, FAQ, Breadcrumb, Product)
+- `references/technical-seo.md` — Technical SEO checklist (metadata, sitemaps, hreflang, robots.txt)
+- `references/aeo-considerations.md` — AI/AEO considerations (AI Overviews, crawler management)
diff --git a/.agents/skills/seo-aeo-best-practices/references/aeo-considerations.md b/.agents/skills/seo-aeo-best-practices/references/aeo-considerations.md
new file mode 100644
index 000000000..227d79e8e
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/aeo-considerations.md
@@ -0,0 +1,159 @@
+# AI/AEO Considerations
+
+Answer Engine Optimization (AEO) prepares content to be selected as authoritative answers by AI systems like ChatGPT, Perplexity, Google AI Overviews, and Bing Copilot.
+
+## How AI Selects Answers
+
+AI systems evaluate content based on:
+
+1. **Clarity:** Is the answer direct and easy to extract?
+2. **Authority:** Is the source trustworthy?
+3. **Comprehensiveness:** Does it fully address the question?
+4. **Recency:** Is the information up to date?
+5. **Structure:** Can the AI parse and understand it?
+
+## Content Structure for AI
+
+### Direct Answers First
+Lead with the answer, then explain.
+
+**Bad:**
+> The history of JavaScript dates back to 1995 when Brendan Eich... [500 words later] ...JavaScript runs in the browser.
+
+**Good:**
+> JavaScript is a programming language that runs in web browsers. It was created in 1995 by Brendan Eich...
+
+### Clear Headings
+Use descriptive H2/H3 headings that match user questions.
+
+**Bad:** "Overview" → "Details" → "More Information"
+**Good:** "What is X?" → "How does X work?" → "When should you use X?"
+
+### Lists and Tables
+AI extracts structured information more easily than prose.
+
+```markdown
+## Benefits of Structured Content
+
+- **Reusability:** Use content across channels
+- **Flexibility:** Change presentation without changing content
+- **Scalability:** Manage large content volumes
+```
+
+### FAQ Format
+Question-answer pairs are ideal for AI extraction.
+
+```typescript
+// Schema for AI-friendly FAQs
+defineType({
+ name: 'faq',
+ type: 'document',
+ fields: [
+ defineField({ name: 'question', type: 'string' }),
+ defineField({ name: 'answer', type: 'text' }),
+ defineField({ name: 'category', type: 'reference', to: [{ type: 'faqCategory' }] }),
+ ]
+})
+```
+
+## Technical Implementation
+
+### Structured Data (Critical)
+JSON-LD helps AI understand content type and relationships.
+
+```typescript
+// FAQ structured data
+const faqSchema = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqs.map(faq => ({
+ "@type": "Question",
+ name: faq.question,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: faq.answer
+ }
+ }))
+}
+```
+
+### Canonical Content
+Ensure AI finds your authoritative version, not copies.
+
+- Set canonical URLs
+- Avoid duplicate content across pages
+- Use `rel="canonical"` for syndicated content
+
+### Freshness Signals
+AI systems prefer current information.
+
+- Display publish and update dates prominently
+- Update content regularly with substantive changes (superficial updates like changing dates without meaningful edits can be counterproductive)
+- Use `dateModified` in structured data
+
+## Content Quality Signals
+
+### Author Credentials
+AI systems increasingly check author authority.
+
+- Display author name and credentials
+- Link to author profiles
+- Include author structured data
+
+### Citations and Sources
+Linking to authoritative sources increases trust.
+
+- Cite primary sources
+- Link to studies, documentation, official sources
+- Avoid circular citations (sites citing each other)
+
+### Comprehensive Coverage
+AI prefers content that fully answers questions.
+
+- Cover related questions users might have
+- Include definitions for technical terms
+- Address common misconceptions
+
+## Google AI Overviews
+
+Google's AI Overviews (formerly SGE) now appear in many search results. To optimize:
+
+- **Be the cited source:** AI Overviews cite specific pages. Concise, authoritative answers increase citation likelihood.
+- **Structure for extraction:** Use clear headings, direct answers, and lists that AI can easily parse.
+- **Cover follow-up questions:** AI Overviews often address related queries. Anticipate and answer them on the same page or link to dedicated pages.
+- **Monitor in Search Console:** Google Search Console provides data on AI Overview impressions and clicks.
+
+## AI Crawler Management
+
+Make conscious decisions about which AI systems can crawl your content:
+
+- **robots.txt directives:** Use `User-agent: GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended` to control access.
+- **Allowing crawlers** increases chances of being cited as a source in AI responses.
+- **Blocking crawlers** prevents content from being used in AI training (but may reduce AI citations).
+- Review your policy regularly — this is one of the most actively evolving areas of SEO.
+
+## Measuring AEO Success
+
+### Monitor AI Mentions
+Track when AI assistants cite your content:
+- Use Google Search Console's AI Overview data for impression and click tracking
+- Monitor referral traffic from AI platforms (Perplexity, ChatGPT, Bing Copilot)
+- Search for your brand + "according to" in AI assistants
+- Consider third-party AEO tracking tools for comprehensive monitoring
+
+### Track Zero-Click Queries
+If AI answers questions directly, traditional rankings matter less.
+
+### Featured Snippet Capture
+Featured snippets often become AI answers. Track which you own.
+
+## AEO vs SEO Balance
+
+AEO and SEO largely align—quality content serves both. Key differences:
+
+| Aspect | SEO Focus | AEO Focus |
+|--------|-----------|-----------|
+| Goal | Rank on page 1 | Be THE answer |
+| Format | Varies | Direct, structured |
+| Length | Often longer | Concise + comprehensive |
+| Links | Link building | Source citations |
diff --git a/.agents/skills/seo-aeo-best-practices/references/eeat-principles.md b/.agents/skills/seo-aeo-best-practices/references/eeat-principles.md
new file mode 100644
index 000000000..899a12039
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/eeat-principles.md
@@ -0,0 +1,127 @@
+# EEAT Principles
+
+Google's EEAT framework (Experience, Expertise, Authoritativeness, Trustworthiness) guides how content quality is evaluated. This applies to both SEO rankings and AI answer selection.
+
+## The Four Pillars
+
+### Experience
+First-hand or life experience with the topic.
+
+**Signals:**
+- Personal anecdotes and case studies
+- "I tested this" content
+- Real-world results and screenshots
+- User-generated reviews
+
+**Implementation:**
+- Include author bios with relevant experience
+- Add "About the Author" sections
+- Feature customer testimonials
+- Show real examples, not just theory
+
+### Expertise
+Knowledge and skill in the subject area.
+
+**Signals:**
+- Credentials and qualifications
+- Depth of content coverage
+- Technical accuracy
+- Citations to authoritative sources
+
+**Implementation:**
+- Display author credentials
+- Link to primary sources
+- Cover topics comprehensively
+- Keep content technically accurate and updated
+
+### Authoritativeness
+Recognition as a go-to source in the field.
+
+**Signals:**
+- Backlinks from respected sites
+- Mentions in industry publications
+- Social proof and follower counts
+- Brand recognition
+
+**Implementation:**
+- Build thought leadership content
+- Contribute to industry publications
+- Maintain consistent publishing
+- Develop recognizable brand voice
+
+### Trustworthiness
+Accuracy, transparency, and legitimacy.
+
+**Signals:**
+- Clear authorship and contact info
+- Accurate, fact-checked content
+- Secure website (HTTPS)
+- Privacy policy and terms
+
+**Implementation:**
+- Display clear author attribution
+- Include publication and update dates
+- Provide contact information
+- Use HTTPS and maintain security
+
+## Sanity Implementation
+
+```typescript
+// Author schema with EEAT signals
+defineType({
+ name: 'author',
+ type: 'document',
+ fields: [
+ defineField({ name: 'name', type: 'string' }),
+ defineField({ name: 'role', type: 'string' }),
+ defineField({ name: 'bio', type: 'text' }),
+ defineField({ name: 'credentials', type: 'array', of: [{ type: 'string' }] }),
+ defineField({ name: 'image', type: 'image' }),
+ // sameAs: used for schema.org Person structured data output
+ defineField({ name: 'sameAs', type: 'array', of: [{ type: 'url' }],
+ description: 'Canonical profile URLs (LinkedIn, Twitter, etc.) for schema.org Person'
+ }),
+ // socialLinks: used for display purposes (platform icons, labels)
+ defineField({
+ name: 'socialLinks',
+ type: 'array',
+ of: [{ type: 'object', fields: [
+ defineField({ name: 'platform', type: 'string' }),
+ defineField({ name: 'url', type: 'url' })
+ ]}],
+ description: 'Social links for display in the UI. Use sameAs for structured data output.'
+ }),
+ ]
+})
+
+// Content with EEAT metadata
+defineType({
+ name: 'post',
+ fields: [
+ defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
+ defineField({ name: 'publishedAt', type: 'datetime' }),
+ defineField({ name: 'updatedAt', type: 'datetime' }),
+ defineField({
+ name: 'reviewedBy',
+ type: 'reference',
+ to: [{ type: 'author' }],
+ description: 'Expert reviewer for fact-checking'
+ }),
+ defineField({
+ name: 'sources',
+ type: 'array',
+ of: [{ type: 'url' }],
+ description: 'Citations and references'
+ }),
+ ]
+})
+```
+
+## YMYL Considerations
+
+"Your Money or Your Life" topics (health, finance, legal, safety) require extra EEAT rigor:
+
+- Medical content reviewed by healthcare professionals
+- Financial advice from certified experts
+- Legal content reviewed by attorneys
+- Clear disclaimers where appropriate
diff --git a/.agents/skills/seo-aeo-best-practices/references/structured-data.md b/.agents/skills/seo-aeo-best-practices/references/structured-data.md
new file mode 100644
index 000000000..84292dc32
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/structured-data.md
@@ -0,0 +1,183 @@
+# Structured Data (JSON-LD)
+
+Structured data helps search engines and AI understand your content. JSON-LD is the recommended format.
+
+## Why Structured Data Matters
+
+- **Rich snippets:** Enhanced search result appearance
+- **Knowledge panels:** Featured information boxes
+- **AI training:** Better content understanding
+- **Voice search:** Answer selection for voice queries
+
+## Common Schema Types
+
+### Article / Blog Post
+
+```typescript
+import { Article, WithContext } from 'schema-dts'
+
+const articleSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ headline: post.title,
+ description: post.excerpt,
+ image: post.image?.url,
+ datePublished: post.publishedAt,
+ dateModified: post.updatedAt,
+ author: {
+ "@type": "Person",
+ name: post.author.name,
+ url: post.author.url
+ },
+ publisher: {
+ "@type": "Organization",
+ name: "Your Company",
+ logo: {
+ "@type": "ImageObject",
+ url: "https://example.com/logo.png"
+ }
+ }
+}
+```
+
+### FAQ Page
+
+```typescript
+import { FAQPage, WithContext } from 'schema-dts'
+
+const faqSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqs.map(faq => ({
+ "@type": "Question",
+ name: faq.question,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: faq.answer // Plain text, use pt::text() in GROQ
+ }
+ }))
+}
+```
+
+### Organization
+
+```typescript
+import { Organization, WithContext } from 'schema-dts'
+
+const orgSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ name: "Your Company",
+ url: "https://example.com",
+ logo: "https://example.com/logo.png",
+ sameAs: [
+ "https://twitter.com/company",
+ "https://linkedin.com/company/company"
+ ],
+ contactPoint: {
+ "@type": "ContactPoint",
+ telephone: "+1-555-555-5555",
+ contactType: "customer service"
+ }
+}
+```
+
+### Product
+
+```typescript
+import { Product, WithContext } from 'schema-dts'
+
+const productSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Product",
+ name: product.name,
+ description: product.description,
+ image: product.images,
+ offers: {
+ "@type": "Offer",
+ price: product.price,
+ priceCurrency: "USD",
+ availability: "https://schema.org/InStock"
+ },
+ aggregateRating: product.rating ? {
+ "@type": "AggregateRating",
+ ratingValue: product.rating.average,
+ reviewCount: product.rating.count
+ } : undefined
+}
+```
+
+### Breadcrumb
+
+```typescript
+import { BreadcrumbList, WithContext } from 'schema-dts'
+
+const breadcrumbSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: breadcrumbs.map((crumb, index) => ({
+ "@type": "ListItem",
+ position: index + 1, // schema.org positions are 1-based
+ name: crumb.title,
+ item: `https://example.com${crumb.path}`
+ }))
+}
+```
+
+## Combining Multiple Schemas (@graph)
+
+Real-world pages often need multiple schema types. Use `@graph` to combine them. The `@context` is defined once at the top level — omit it from individual schema generators when used inside `@graph`:
+
+```typescript
+const pageSchema = {
+ "@context": "https://schema.org",
+ "@graph": [
+ generateArticleSchema(post), // No @context needed here
+ generateBreadcrumbSchema(breadcrumbs),
+ generateOrganizationSchema(),
+ ]
+}
+```
+
+## Implementation in Next.js
+
+```typescript
+// Component to render JSON-LD
+// Ensure data comes from trusted sources (your CMS).
+// If data could contain user-generated content, strip HTML tags
+// and escape special characters before passing to JSON.stringify.
+function JsonLd({ data }: { data: WithContext }) {
+ return (
+
+ )
+}
+
+// Usage in page
+export default function PostPage({ post }) {
+ return (
+ <>
+
+ ...
+ >
+ )
+}
+```
+
+## GROQ for Plain Text
+
+Structured data often needs plain text, not rich text:
+
+```groq
+*[_type == "faq"]{
+ question,
+ "answer": pt::text(answerRichText) // Convert Portable Text to plain string
+}
+```
+
+## Testing Tools
+
+- [Google Rich Results Test](https://search.google.com/test/rich-results)
+- [Schema.org Validator](https://validator.schema.org/)
diff --git a/.agents/skills/seo-aeo-best-practices/references/technical-seo.md b/.agents/skills/seo-aeo-best-practices/references/technical-seo.md
new file mode 100644
index 000000000..a0ba6e668
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/technical-seo.md
@@ -0,0 +1,188 @@
+# Technical SEO Checklist
+
+Essential technical SEO elements for modern web applications.
+
+## Table of Contents
+
+- Metadata
+- Sitemaps
+- Canonical URLs
+- Redirects
+- Performance
+- Robots.txt
+- International SEO
+
+## Metadata
+
+### Title Tags
+- Unique per page
+- 50-60 characters
+- Primary keyword near the beginning
+- Brand name at the end (optional)
+
+### Meta Descriptions
+- Unique per page
+- 150-160 characters
+- Include call-to-action
+- Contain relevant keywords
+
+### Open Graph
+```html
+
+
+
+
+
+```
+
+### Sanity + Next.js Implementation
+
+```typescript
+export async function generateMetadata({ params }): Promise {
+ const { data } = await sanityFetch({
+ query: PAGE_QUERY,
+ stega: false, // Critical: no stega in metadata
+ })
+
+ return {
+ title: data.seo?.title || data.title,
+ description: data.seo?.description,
+ openGraph: {
+ images: data.seo?.image ? [{
+ url: urlFor(data.seo.image).width(1200).height(630).url(),
+ width: 1200,
+ height: 630,
+ }] : [],
+ },
+ robots: data.seo?.noIndex ? 'noindex' : undefined,
+ }
+}
+```
+
+## Sitemaps
+
+Dynamic sitemap from CMS content:
+
+```typescript
+// app/sitemap.ts
+import { MetadataRoute } from 'next'
+
+export default async function sitemap(): Promise {
+ const pages = await client.fetch(`
+ *[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true]{
+ "url": select(
+ _type == "page" => "/" + slug.current,
+ _type == "post" => "/blog/" + slug.current
+ ),
+ _updatedAt
+ }
+ `)
+
+ return pages.map(page => ({
+ url: `https://example.com${page.url}`,
+ lastModified: new Date(page._updatedAt),
+ // Note: changeFrequency and priority are largely ignored by Google
+ // but may be used by other search engines
+ }))
+}
+```
+
+## Canonical URLs
+
+Prevent duplicate content issues:
+
+```typescript
+export async function generateMetadata({ params }): Promise {
+ return {
+ alternates: {
+ canonical: `https://example.com/${params.slug}`,
+ },
+ }
+}
+```
+
+## Redirects
+
+CMS-managed redirects:
+
+```typescript
+// next.config.ts
+async redirects() {
+ const redirects = await client.fetch(`
+ *[_type == "redirect" && isEnabled == true]{
+ source,
+ destination,
+ permanent
+ }
+ `)
+ return redirects
+}
+```
+
+## Performance
+
+[Core Web Vitals](https://web.dev/articles/defining-core-web-vitals-thresholds) impact rankings:
+
+- **LCP (Largest Contentful Paint):** < 2.5s
+- **INP (Interaction to Next Paint):** < 200ms
+- **CLS (Cumulative Layout Shift):** < 0.1
+
+### Image Optimization (Next.js example)
+- Use `next/image` with Sanity URL builder
+- Serve WebP/AVIF formats
+- Implement LQIP blur placeholders
+- Set explicit dimensions
+
+### Font Loading (Next.js example)
+```typescript
+// Prevent layout shift
+import { Inter } from 'next/font/google'
+const inter = Inter({ subsets: ['latin'], display: 'swap' })
+```
+
+## Robots.txt
+
+```
+# public/robots.txt
+User-agent: *
+Allow: /
+Disallow: /api/
+Disallow: /studio/
+
+# AI crawlers — allow or block based on your content strategy
+# Uncomment to block specific AI crawlers:
+# User-agent: GPTBot
+# Disallow: /
+# User-agent: ClaudeBot
+# Disallow: /
+# User-agent: PerplexityBot
+# Disallow: /
+# User-agent: Google-Extended
+# Disallow: /
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+**AI crawler considerations:** Decide whether AI training crawlers should access your content. Blocking `Google-Extended` prevents AI training use while still allowing Google Search indexing. Review your policy regularly as this landscape evolves.
+
+## International SEO (hreflang)
+
+For multi-language sites, implement hreflang tags to indicate language/region variants:
+
+```typescript
+export async function generateMetadata({ params }: { params: Promise<{ lang: string; slug: string }> }): Promise {
+ const { lang, slug } = await params
+ return {
+ alternates: {
+ canonical: `https://example.com/${lang}/${slug}`,
+ languages: {
+ 'en': `https://example.com/en/${slug}`,
+ 'de': `https://example.com/de/${slug}`,
+ 'x-default': `https://example.com/en/${slug}`,
+ },
+ },
+ }
+}
+```
+
+Include all language variants in sitemaps with `hreflang` annotations for proper indexing.
diff --git a/.github/.branch-cleanup b/.github/.branch-cleanup
new file mode 100644
index 000000000..94467fc44
--- /dev/null
+++ b/.github/.branch-cleanup
@@ -0,0 +1 @@
+cleanup
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index c937ba600..1c81e474e 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,4 +1,4 @@
-name: Deploy CodingCat.dev
+name: Deploy Sanity Studio
on:
push:
@@ -23,23 +23,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Install wrangler
- run: pnpm add -Dw wrangler
-
- - name: Build
- run: pnpm --filter @codingcatdev/web build
- env:
- SANITY_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }}
- SANITY_DATASET: ${{ vars.SANITY_STUDIO_DATASET }}
- PUBLIC_SANITY_STUDIO_URL: https://${{ vars.SANITY_STUDIO_HOSTNAME }}.sanity.studio
-
- - name: Deploy to Cloudflare
- run: pnpm exec wrangler deploy
- working-directory: apps/web
- env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
-
- name: Deploy Sanity Studio
run: npx sanity deploy -y
working-directory: apps/sanity
@@ -65,22 +48,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Install wrangler
- run: pnpm add -Dw wrangler
-
- - name: Build
- run: pnpm --filter @codingcatdev/web build
- env:
- SANITY_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }}
- SANITY_DATASET: ${{ vars.SANITY_STUDIO_DATASET }}
-
- - name: Deploy to Cloudflare
- run: pnpm exec wrangler deploy --env production
- working-directory: apps/web
- env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
-
- name: Deploy Sanity Studio
run: npx sanity deploy -y
working-directory: apps/sanity
diff --git a/.gitignore b/.gitignore
index 2059b78a4..c14bd61fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ node_modules/
# build output
dist/
+.next/
# astro
.astro/
diff --git a/apps/sanity/extract.json b/apps/sanity/extract.json
new file mode 100644
index 000000000..102eb6dc0
--- /dev/null
+++ b/apps/sanity/extract.json
@@ -0,0 +1,14884 @@
+[
+ {
+ "type": "type",
+ "name": "sanity.imageAsset.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "sanity.imageAsset"
+ }
+ },
+ {
+ "type": "type",
+ "name": "coverImage",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "media"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "socials",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "bluesky": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "codepen": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "discord": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "dribble": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "facebook": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "github": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "instagram": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "lastfm": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "linkedin": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "email": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "mastodon": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "medium": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "polywork": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "stackoverflow": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "substack": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "tiktok": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "twitch": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "twitter": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "link",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "sanity.fileAsset.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "sanity.fileAsset"
+ }
+ },
+ {
+ "type": "type",
+ "name": "videoCloudinary",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.fileAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "videoCloudinary.media"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "file"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "statistics",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "youtube"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "youtube",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "commentCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "favoriteCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "likeCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "viewCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "ogImage",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "ogImage.media"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "guid",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "isPermaLink": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "itunes",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "summary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "explicit": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "duration": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "season": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "episode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "episodeType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "image": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "itunes.image"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "itunes.image",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "post.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "post"
+ }
+ },
+ {
+ "type": "type",
+ "name": "podcast.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "podcast"
+ }
+ },
+ {
+ "type": "type",
+ "name": "page.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "page"
+ }
+ },
+ {
+ "type": "type",
+ "name": "author.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "author"
+ }
+ },
+ {
+ "type": "type",
+ "name": "sponsor.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "sponsor"
+ }
+ },
+ {
+ "type": "type",
+ "name": "lesson.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "lesson"
+ }
+ },
+ {
+ "name": "course",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "course"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "videoCloudinary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "videoCloudinary"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "author": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "author.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "sponsor": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sponsor.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "hashnode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "statistics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "statistics"
+ },
+ "optional": true
+ },
+ "stripeProduct": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sections": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "lesson": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "lesson.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "section"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "lesson",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "lesson"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "videoCloudinary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "videoCloudinary"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "author": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "author.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "sponsor": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sponsor.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "hashnode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "statistics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "statistics"
+ },
+ "optional": true
+ },
+ "locked": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "category.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "category"
+ }
+ },
+ {
+ "name": "short",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "short"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "thumbnail": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "parentEpisode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ "optional": true
+ },
+ "publishedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "duration": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "categories": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "category.reference"
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sanity.imageCrop",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imageCrop"
+ }
+ },
+ "top": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "bottom": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "left": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "right": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.imageHotspot",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imageHotspot"
+ }
+ },
+ "x": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "y": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "height": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "width": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "slug",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "slug"
+ }
+ },
+ "current": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "source": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "category",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "category"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "color": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sponsorPool",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sponsorPool"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "companyName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "contactName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "contactEmail": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "website": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "category": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "source": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "curated"
+ },
+ {
+ "type": "string",
+ "value": "enriched"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "relevanceScore": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "lastContactedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "optedOut": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "notes": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "automatedVideo.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "automatedVideo"
+ }
+ },
+ {
+ "name": "videoAnalytics",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "videoAnalytics"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "contentRef": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "automatedVideo.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "contentType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "post"
+ },
+ {
+ "type": "string",
+ "value": "podcast"
+ },
+ {
+ "type": "string",
+ "value": "automatedVideo"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "youtubeId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "youtubeShortId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "viewCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "likeCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "commentCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "favoriteCount": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "lastFetchedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "mediaAsset",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "mediaAsset"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "image": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "source": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "simpleicons"
+ },
+ {
+ "type": "string",
+ "value": "brandfetch"
+ },
+ {
+ "type": "string",
+ "value": "serper"
+ },
+ {
+ "type": "string",
+ "value": "screenshot"
+ },
+ {
+ "type": "string",
+ "value": "gemini"
+ },
+ {
+ "type": "string",
+ "value": "pexels"
+ },
+ {
+ "type": "string",
+ "value": "manual"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "attribution": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "license": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "cc0"
+ },
+ {
+ "type": "string",
+ "value": "cc-by"
+ },
+ {
+ "type": "string",
+ "value": "cc-by-sa"
+ },
+ {
+ "type": "string",
+ "value": "fair-use"
+ },
+ {
+ "type": "string",
+ "value": "proprietary"
+ },
+ {
+ "type": "string",
+ "value": "unknown"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "brandOverlayApplied": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "originalUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "tags": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "contentIdea.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "contentIdea"
+ }
+ },
+ {
+ "type": "type",
+ "name": "mediaAsset.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "mediaAsset"
+ }
+ },
+ {
+ "type": "type",
+ "name": "sponsorLead.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "sponsorLead"
+ }
+ },
+ {
+ "name": "automatedVideo",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "automatedVideo"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "contentIdea": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "contentIdea.reference"
+ },
+ "optional": true
+ },
+ "script": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "hook": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "scenes": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "sceneNumber": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "narration": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "visualDescription": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "bRollKeywords": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "durationEstimate": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "sceneType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "narration"
+ },
+ {
+ "type": "string",
+ "value": "code"
+ },
+ {
+ "type": "string",
+ "value": "list"
+ },
+ {
+ "type": "string",
+ "value": "comparison"
+ },
+ {
+ "type": "string",
+ "value": "mockup"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "imagePrompts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "code": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "snippet": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "language": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "typescript"
+ },
+ {
+ "type": "string",
+ "value": "javascript"
+ },
+ {
+ "type": "string",
+ "value": "jsx"
+ },
+ {
+ "type": "string",
+ "value": "tsx"
+ },
+ {
+ "type": "string",
+ "value": "css"
+ },
+ {
+ "type": "string",
+ "value": "html"
+ },
+ {
+ "type": "string",
+ "value": "json"
+ },
+ {
+ "type": "string",
+ "value": "bash"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "highlightLines": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "number"
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "list": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "items": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "icon": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "comparison": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "leftLabel": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "rightLabel": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "rows": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "left": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "right": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "mockup": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "deviceType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "browser"
+ },
+ {
+ "type": "string",
+ "value": "phone"
+ },
+ {
+ "type": "string",
+ "value": "terminal"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "screenContent": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "cta": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "scriptQualityScore": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "status": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "draft"
+ },
+ {
+ "type": "string",
+ "value": "researching"
+ },
+ {
+ "type": "string",
+ "value": "research_complete"
+ },
+ {
+ "type": "string",
+ "value": "generating_media"
+ },
+ {
+ "type": "string",
+ "value": "generating_audio"
+ },
+ {
+ "type": "string",
+ "value": "assembling_video"
+ },
+ {
+ "type": "string",
+ "value": "quality_gate"
+ },
+ {
+ "type": "string",
+ "value": "pending_review"
+ },
+ {
+ "type": "string",
+ "value": "approved"
+ },
+ {
+ "type": "string",
+ "value": "publishing"
+ },
+ {
+ "type": "string",
+ "value": "published"
+ },
+ {
+ "type": "string",
+ "value": "rejected"
+ },
+ {
+ "type": "string",
+ "value": "archived"
+ },
+ {
+ "type": "string",
+ "value": "script_ready"
+ },
+ {
+ "type": "string",
+ "value": "audio_gen"
+ },
+ {
+ "type": "string",
+ "value": "video_gen"
+ },
+ {
+ "type": "string",
+ "value": "rendering"
+ },
+ {
+ "type": "string",
+ "value": "uploading"
+ },
+ {
+ "type": "string",
+ "value": "flagged"
+ },
+ {
+ "type": "string",
+ "value": "infographics_generating"
+ },
+ {
+ "type": "string",
+ "value": "enriching"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "qualityScore": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "qualityIssues": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "reviewedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "reviewedBy": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "mediaAssets": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "mediaAsset.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "startScene": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "endScene": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "videoUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "thumbnailUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "thumbnailHorizontal": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "thumbnailVertical": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "blogPostId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "socialPosts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "platform": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "youtube"
+ },
+ {
+ "type": "string",
+ "value": "twitter"
+ },
+ {
+ "type": "string",
+ "value": "linkedin"
+ },
+ {
+ "type": "string",
+ "value": "tiktok"
+ },
+ {
+ "type": "string",
+ "value": "instagram"
+ },
+ {
+ "type": "string",
+ "value": "bluesky"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "postId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "postUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "postedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "workflowId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "r2Prefix": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "audioFile": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.fileAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "file"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "audioUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "videoFile": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.fileAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "file"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "videoUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "shortFile": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.fileAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "file"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "shortUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "infographics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "alt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "caption": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "infographicsHorizontal": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "image": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "prompt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sceneNumber": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "infographicsVertical": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "image": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "prompt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sceneNumber": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "renderData": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "mainRenderId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "shortRenderId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "bucketName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "startedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "youtubeId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "youtubeShortId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "scheduledPublishAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sponsorSlot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sponsorLead.reference"
+ },
+ "optional": true
+ },
+ "researchNotebookId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "researchTaskId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "researchInteractionId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "researchData": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "infographicArtifactIds": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "trendScore": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "trendSources": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "flaggedReason": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "distributionLog": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "step": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "status": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "success"
+ },
+ {
+ "type": "string",
+ "value": "failed"
+ },
+ {
+ "type": "string",
+ "value": "skipped"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "error": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "timestamp": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "result": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sponsorLead",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sponsorLead"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "companyName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "contactName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "contactEmail": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "source": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "inbound"
+ },
+ {
+ "type": "string",
+ "value": "outbound"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "status": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "new"
+ },
+ {
+ "type": "string",
+ "value": "contacted"
+ },
+ {
+ "type": "string",
+ "value": "replied"
+ },
+ {
+ "type": "string",
+ "value": "negotiating"
+ },
+ {
+ "type": "string",
+ "value": "booked"
+ },
+ {
+ "type": "string",
+ "value": "paid"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "intent": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "rateCard": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "stripeInvoiceId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "bookedSlot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "automatedVideo.reference"
+ },
+ "optional": true
+ },
+ "sponsorDocId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sponsor.reference"
+ },
+ "optional": true
+ },
+ "threadId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "lastEmailAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "notes": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "contentIdea",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "contentIdea"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "sourceUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "summary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "topics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "collectedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "status": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "new"
+ },
+ {
+ "type": "string",
+ "value": "approved"
+ },
+ {
+ "type": "string",
+ "value": "rejected"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "reasonRejected": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sponsorshipRequest",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sponsorshipRequest"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "fullName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "email": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "companyName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sponsorshipTier": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "message": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "previewSession",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "previewSession"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "token": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "documentId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "expiresAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sponsor",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sponsor"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "ogTitle": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogDescription": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "ogImage"
+ },
+ "optional": true
+ },
+ "twitterCardType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "summary_large_image"
+ },
+ {
+ "type": "string",
+ "value": "summary"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "noIndex": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "author",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "author"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "socials": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "socials"
+ },
+ "optional": true
+ },
+ "websites": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "site": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "link": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "link"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "site"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "post",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "post"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "videoCloudinary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "videoCloudinary"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "author": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "author.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "sponsor": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sponsor.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "hashnode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "statistics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "statistics"
+ },
+ "optional": true
+ },
+ "ogTitle": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogDescription": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "ogImage"
+ },
+ "optional": true
+ },
+ "twitterCardType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "summary_large_image"
+ },
+ {
+ "type": "string",
+ "value": "summary"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "noIndex": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "categories": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "category.reference"
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "podcastType.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "podcastType"
+ }
+ },
+ {
+ "type": "type",
+ "name": "guest.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "guest"
+ }
+ },
+ {
+ "type": "type",
+ "name": "podcastSeries.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "podcastSeries"
+ }
+ },
+ {
+ "type": "type",
+ "name": "short.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "short"
+ }
+ },
+ {
+ "name": "podcast",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "podcast"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "videoCloudinary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "videoCloudinary"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "author": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "author.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "sponsor": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sponsor.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "hashnode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "statistics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "statistics"
+ },
+ "optional": true
+ },
+ "ogTitle": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogDescription": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "ogImage"
+ },
+ "optional": true
+ },
+ "twitterCardType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "summary_large_image"
+ },
+ {
+ "type": "string",
+ "value": "summary"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "noIndex": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "podcastType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "podcastType.reference"
+ },
+ "optional": true
+ },
+ "season": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "episode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "recordingDate": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "guest": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "guest.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "pick": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "user": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "guest.reference"
+ },
+ {
+ "type": "inline",
+ "name": "author.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "name": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "site": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "spotify": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "podcastRssEpisode"
+ },
+ "optional": true
+ },
+ "thumbnail": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "duration": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "chapters": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "timestamp": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "seconds": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "series": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "podcastSeries.reference"
+ },
+ "optional": true
+ },
+ "seriesOrder": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "listenLinks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "spotify": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "apple": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "overcast": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "pocketCasts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "rss": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "transcript": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "contentType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "interview"
+ },
+ {
+ "type": "string",
+ "value": "solo"
+ },
+ {
+ "type": "string",
+ "value": "tutorial"
+ },
+ {
+ "type": "string",
+ "value": "news"
+ },
+ {
+ "type": "string",
+ "value": "review"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "relatedShorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "short.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "relatedBlogPost": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "podcastSeries",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "podcastSeries"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "youtubePlaylistId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "isActive": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "podcastRssEpisode",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "podcastRssEpisode"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "link": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "guid": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "guid"
+ },
+ "optional": true
+ },
+ "pubDate": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "enclosures": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "length": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "enclosure"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "itunes": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "itunes"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "guest",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "guest"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "socials": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "socials"
+ },
+ "optional": true
+ },
+ "websites": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "site": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "link": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "link"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "site"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "company": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "role": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "podcastType",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "podcastType"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "page",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "page"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "coverImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "coverImage"
+ },
+ "optional": true
+ },
+ "date": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "slug": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ },
+ "excerpt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "featured": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "code"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtube"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "shorts": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "youtubeShorts"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codepen"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "codesandbox"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "twitter"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "html": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "htmlBlock"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "content": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ },
+ {
+ "type": "string",
+ "value": "h1"
+ },
+ {
+ "type": "string",
+ "value": "h2"
+ },
+ {
+ "type": "string",
+ "value": "h3"
+ },
+ {
+ "type": "string",
+ "value": "h4"
+ },
+ {
+ "type": "string",
+ "value": "h5"
+ },
+ {
+ "type": "string",
+ "value": "h6"
+ },
+ {
+ "type": "string",
+ "value": "blockquote"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "bullet"
+ },
+ {
+ "type": "string",
+ "value": "number"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blank": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "inline",
+ "name": "post.reference"
+ },
+ {
+ "type": "inline",
+ "name": "podcast.reference"
+ },
+ {
+ "type": "inline",
+ "name": "page.reference"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "internalLink"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "quote"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "table"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "videoCloudinary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "videoCloudinary"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "author": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "author.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "sponsor": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sponsor.reference"
+ }
+ }
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "hashnode": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "statistics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "statistics"
+ },
+ "optional": true
+ },
+ "ogTitle": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogDescription": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "ogImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "ogImage"
+ },
+ "optional": true
+ },
+ "twitterCardType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "summary_large_image"
+ },
+ {
+ "type": "string",
+ "value": "summary"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "noIndex": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "engineConfig",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "engineConfig"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "autoPublish": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "qualityThreshold": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "reviewTimeoutDays": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "reviewNotification": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "email"
+ },
+ {
+ "type": "string",
+ "value": "slack"
+ },
+ {
+ "type": "string",
+ "value": "webhook"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "maxIdeasPerRun": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "stuckTimeoutMinutes": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "longFormPerWeek": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "shortsPerDay": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "blogsPerWeek": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "newsletterFrequency": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "weekly"
+ },
+ {
+ "type": "string",
+ "value": "biweekly"
+ },
+ {
+ "type": "string",
+ "value": "monthly"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "publishDays": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "contentCategories": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "trendSources": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "topicFocus": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "rssFeeds": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "name": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "trendSourcesEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "hn": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "devto": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "blogs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "youtube": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "github": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ },
+ "optional": true
+ },
+ "dedupWindowDays": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "geminiModel": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "infographicModel": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "infographicPromptPrefix": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "systemInstruction": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "deepResearchAgent": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "deepResearchPromptTemplate": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "enableDeepResearch": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "enableHorizontalInfographics": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "thumbnailEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "infographicInstructions": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "targetVideoDurationSec": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "sceneCountMin": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "sceneCountMax": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "elevenLabsVoiceId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "youtubeEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "twitterEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "linkedinEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "tiktokEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "instagramEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "blueskyEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "newsletterEnabled": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "youtubeUploadVisibility": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "private"
+ },
+ {
+ "type": "string",
+ "value": "unlisted"
+ },
+ {
+ "type": "string",
+ "value": "public"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "youtubeChannelId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "youtubeDescriptionTemplate": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "youtubeDefaultTags": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "notificationEmails": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "resendFromEmail": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "cooldownDays": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "rateCardTiers": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "name": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "price": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "outreachEmailTemplate": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "maxOutreachPerRun": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "brandPrimary": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "brandBackground": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "brandText": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "awsRegion": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "remotionFunctionName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "remotionServeUrl": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "gcsBucketName": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "gcsProjectId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "settings",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "settings"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": []
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "href": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "link"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "navLinks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sideOnly": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "ogImage": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "asset": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageAsset.reference"
+ },
+ "optional": true
+ },
+ "media": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "unknown"
+ },
+ "optional": true
+ },
+ "hotspot": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageHotspot"
+ },
+ "optional": true
+ },
+ "crop": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageCrop"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "image"
+ }
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "row",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "row"
+ }
+ },
+ "cells": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "table",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "table"
+ }
+ },
+ "rows": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "row"
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "code",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "code"
+ }
+ },
+ "language": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "filename": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "code": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "highlightedLines": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "number"
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "media.tag",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "media.tag"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "name": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "slug"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.instructionTask",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.instructionTask"
+ }
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "instructionKey": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "started": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "updated": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "info": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.task.status",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.task.status"
+ }
+ },
+ "tasks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.instructionTask"
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.schemaType.annotations",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.schemaType.annotations"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "fields": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.schemaType.field"
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.output.type",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.output.type"
+ }
+ },
+ "type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.output.field",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.output.field"
+ }
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "type": "type",
+ "name": "assist.instruction.context.reference",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_ref": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "reference"
+ }
+ },
+ "_weak": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ },
+ "dereferencesTo": "assist.instruction.context"
+ }
+ },
+ {
+ "name": "sanity.assist.instruction.context",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.instruction.context"
+ }
+ },
+ "reference": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "assist.instruction.context.reference"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "assist.instruction.context",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "assist.instruction.context"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "context": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": []
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "null"
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.instruction.userInput",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.instruction.userInput"
+ }
+ },
+ "message": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.instruction.prompt",
+ "type": "type",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "children": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "marks": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "string"
+ }
+ },
+ "optional": true
+ },
+ "text": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "span"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.instruction.fieldRef"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.instruction.context"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.instruction.userInput"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ },
+ "style": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": [
+ {
+ "type": "string",
+ "value": "normal"
+ }
+ ]
+ },
+ "optional": true
+ },
+ "listItem": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "union",
+ "of": []
+ },
+ "optional": true
+ },
+ "markDefs": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "null"
+ },
+ "optional": true
+ },
+ "level": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "block"
+ }
+ }
+ },
+ "rest": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.instruction.fieldRef",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.instruction.fieldRef"
+ }
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.instruction",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.instruction"
+ }
+ },
+ "prompt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.assist.instruction.prompt"
+ },
+ "optional": true
+ },
+ "icon": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "userId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "createdById": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "output": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "union",
+ "of": [
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.output.field"
+ }
+ },
+ {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.output.type"
+ }
+ }
+ ]
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.assist.schemaType.field",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assist.schemaType.field"
+ }
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "instructions": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "array",
+ "of": {
+ "type": "object",
+ "attributes": {
+ "_key": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "rest": {
+ "type": "inline",
+ "name": "sanity.assist.instruction"
+ }
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.imagePaletteSwatch",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imagePaletteSwatch"
+ }
+ },
+ "background": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "foreground": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "population": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.imagePalette",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imagePalette"
+ }
+ },
+ "darkMuted": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ },
+ "lightVibrant": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ },
+ "darkVibrant": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ },
+ "vibrant": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ },
+ "dominant": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ },
+ "lightMuted": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ },
+ "muted": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePaletteSwatch"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.imageDimensions",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imageDimensions"
+ }
+ },
+ "height": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "width": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "aspectRatio": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.imageMetadata",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imageMetadata"
+ }
+ },
+ "location": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "geopoint"
+ },
+ "optional": true
+ },
+ "dimensions": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageDimensions"
+ },
+ "optional": true
+ },
+ "palette": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imagePalette"
+ },
+ "optional": true
+ },
+ "lqip": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "blurHash": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "thumbHash": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "hasAlpha": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ },
+ "isOpaque": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "boolean"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.fileAsset",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.fileAsset"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "originalFilename": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "label": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "altText": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sha1hash": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "extension": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "mimeType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "size": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "assetId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "uploadId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "source": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.assetSourceData"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "sanity.assetSourceData",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.assetSourceData"
+ }
+ },
+ "name": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ },
+ {
+ "name": "sanity.imageAsset",
+ "type": "document",
+ "attributes": {
+ "_id": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "sanity.imageAsset"
+ }
+ },
+ "_createdAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_updatedAt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "_rev": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ }
+ },
+ "originalFilename": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "label": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "title": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "description": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "altText": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "sha1hash": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "extension": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "mimeType": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "size": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "assetId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "uploadId": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "path": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "url": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string"
+ },
+ "optional": true
+ },
+ "metadata": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.imageMetadata"
+ },
+ "optional": true
+ },
+ "source": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "inline",
+ "name": "sanity.assetSourceData"
+ },
+ "optional": true
+ }
+ }
+ },
+ {
+ "name": "geopoint",
+ "type": "type",
+ "value": {
+ "type": "object",
+ "attributes": {
+ "_type": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "string",
+ "value": "geopoint"
+ }
+ },
+ "lat": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "lng": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ },
+ "alt": {
+ "type": "objectAttribute",
+ "value": {
+ "type": "number"
+ },
+ "optional": true
+ }
+ }
+ }
+ }
+]
diff --git a/apps/sanity/package.json b/apps/sanity/package.json
index 9db0ee1e6..2fbec41b4 100644
--- a/apps/sanity/package.json
+++ b/apps/sanity/package.json
@@ -6,7 +6,8 @@
"dev": "sanity dev",
"build": "sanity build",
"deploy": "sanity deploy",
- "typegen": "sanity schema extract --path=./extract.json && sanity typegen generate"
+ "typegen:extract": "sanity schema extract --path=./extract.json --workspace production",
+ "typegen": "pnpm run typegen:extract"
},
"dependencies": {
"@codingcatdev/sanity-plugin-podcast-rss": "^1.0.0",
diff --git a/apps/sanity/sanity.config.ts b/apps/sanity/sanity.config.ts
index bdc63e6d8..81ad148ad 100644
--- a/apps/sanity/sanity.config.ts
+++ b/apps/sanity/sanity.config.ts
@@ -60,11 +60,11 @@ const apiVersion = process.env.SANITY_STUDIO_API_VERSION || "2025-09-30";
const presentationEnabled =
process.env.SANITY_STUDIO_DISABLE_PRESENTATION !== "true";
-// Use local Astro dev server for presentation preview when running Studio locally
+// Use local Next.js dev server for presentation preview when running Studio locally
const isLocal =
typeof import.meta !== "undefined" &&
(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV;
-const localPreviewOrigin = "http://localhost:4321";
+const localPreviewOrigin = "http://localhost:3000";
// ── Shared helpers ───────────────────────────────────────────────────
function resolveHref(type: string, slug?: string): string | undefined {
diff --git a/apps/sanity/schemas/singletons/engineConfig.ts b/apps/sanity/schemas/singletons/engineConfig.ts
index 953e15f1d..9c570a9f3 100644
--- a/apps/sanity/schemas/singletons/engineConfig.ts
+++ b/apps/sanity/schemas/singletons/engineConfig.ts
@@ -104,6 +104,16 @@ export default defineType({
initialValue: 1,
validation: (rule) => rule.min(1).max(10),
}),
+ defineField({
+ name: 'stuckTimeoutMinutes',
+ title: 'Stuck Timeout Minutes',
+ type: 'number',
+ fieldset: 'pipelineControl',
+ description:
+ 'Minutes before a pipeline document is considered stuck and auto-flagged',
+ initialValue: 30,
+ validation: (rule) => rule.min(5).max(120),
+ }),
// ─── Content Cadence ─────────────────────────────────────────────────
defineField({
diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example
deleted file mode 100644
index 48475b5ff..000000000
--- a/apps/web/.dev.vars.example
+++ /dev/null
@@ -1,15 +0,0 @@
-# Runtime secrets for Cloudflare Worker (wrangler dev / preview).
-# Copy to .dev.vars and fill in. In production, set via: wrangler secret put
-
-# Sanity — viewer token for draft mode / Presentation tool
-SANITY_API_READ_TOKEN=
-# Optional: set to enable "Turn on draft mode" from site (visit /api/draft-mode/allow-enable?secret=THIS once per session)
-# SANITY_PREVIEW_DEV_SECRET=
-
-# better-auth
-BETTER_AUTH_SECRET=
-BETTER_AUTH_URL=http://localhost:4321
-
-# Google OAuth (better-auth social login)
-GOOGLE_CLIENT_ID=
-GOOGLE_CLIENT_SECRET=
diff --git a/apps/web/.env.example b/apps/web/.env.example
index edd325a16..24c919a3f 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,13 +1,69 @@
-# Build-time env (Astro/Vite). Copy to .env or .env.local.
-# Runtime secrets live in .dev.vars (see .dev.vars.example).
+# =============================================================================
+# codingcat.dev — Environment Variables
+# =============================================================================
+# Copy this file to .env.local and fill in the values.
+# See: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables
+# Variables prefixed with NEXT_PUBLIC_ are exposed to the browser.
+# All other variables are server-only.
+# =============================================================================
-# Sanity — used at build; defaults exist in code
-SANITY_PROJECT_ID=hfh83o0w
-SANITY_DATASET=production
+# -----------------------------------------------------------------------------
+# Public (client-side) variables — NEXT_PUBLIC_*
+# -----------------------------------------------------------------------------
-# Optional: Visual Editing (local or dev site)
-# PUBLIC_SANITY_VISUAL_EDITING_ENABLED=true
-# PUBLIC_SANITY_STUDIO_URL=http://localhost:3333
+# Sanity CMS — project configuration
+NEXT_PUBLIC_SANITY_PROJECT_ID= # Sanity project ID (from sanity.io/manage)
+NEXT_PUBLIC_SANITY_DATASET= # Sanity dataset name (e.g. "prod")
+NEXT_PUBLIC_SANITY_API_VERSION= # Sanity API version date (e.g. "2024-01-01")
-# Optional: Auth base URL (defaults to http://localhost:4321)
-# PUBLIC_APP_URL=http://localhost:4321
+# Site URLs
+NEXT_PUBLIC_BASE_URL= # Canonical base URL (e.g. "https://codingcat.dev")
+NEXT_PUBLIC_VERCEL_URL= # Vercel preview URL (auto-set by Vercel)
+
+# Algolia search — public keys
+NEXT_PUBLIC_ALGOLIA_APP_ID= # Algolia application ID
+NEXT_PUBLIC_ALGOLIA_INDEX= # Algolia index name
+
+# Analytics
+NEXT_PUBLIC_FB_PIXEL_ID= # Facebook Pixel tracking ID
+
+# Preview mode
+NEXT_PUBLIC_PREVIEW_TOKEN_SECRET= # Secret token for Sanity preview mode
+
+# -----------------------------------------------------------------------------
+# Server-only variables
+# -----------------------------------------------------------------------------
+
+# Sanity CMS — API tokens
+SANITY_API_READ_TOKEN= # Sanity read token (viewer role)
+SANITY_API_WRITE_TOKEN= # Sanity write token (editor role)
+
+# Algolia search — admin keys
+PRIVATE_ALGOLIA_ADMIN_API_KEY= # Algolia admin API key (server-side indexing)
+PRIVATE_ALGOLIA_WEBOOK_SECRET= # Shared secret for Algolia webhook verification
+
+# Syndication
+PRIVATE_SYNDICATE_WEBOOK_SECRET= # Shared secret for syndication webhook verification
+PRIVATE_DEVTO= # Dev.to API key (cross-posting)
+PRIVATE_HASHNODE= # Hashnode API key (cross-posting)
+
+# Cloudflare Turnstile (bot protection)
+CLOUDFLARE_TURNSTILE_SECRET_KEY= # Turnstile server-side secret key
+
+# Cron / scheduled jobs
+CRON_SECRET= # Secret for authenticating cron job requests
+
+# YouTube integration
+YOUTUBE_API_KEY= # YouTube Data API v3 key
+YOUTUBE_CHANNEL_ID= # YouTube channel ID to fetch videos from
+YOUTUBE_UPLOAD_VISIBILITY= # YouTube upload privacy: "public", "private", or "unlisted" (default: "private")
+
+# Vercel
+VERCEL_PROJECT_PRODUCTION_URL= # Production URL (auto-set by Vercel)
+
+# -----------------------------------------------------------------------------
+# Supabase
+# -----------------------------------------------------------------------------
+NEXT_PUBLIC_SUPABASE_URL= # Supabase project URL (e.g. "https://xxx.supabase.co")
+NEXT_PUBLIC_SUPABASE_ANON_KEY= # Supabase anonymous/public key
+SUPABASE_SERVICE_ROLE_KEY= # Supabase service role key (server-only, bypasses RLS)
diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore
new file mode 100644
index 000000000..30764a1a8
--- /dev/null
+++ b/apps/web/.eslintignore
@@ -0,0 +1,2 @@
+# Ignoring generated files
+./sanity.types.ts
diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore
new file mode 100644
index 000000000..1509c4cdb
--- /dev/null
+++ b/apps/web/.prettierignore
@@ -0,0 +1,3 @@
+# Ignoring generated files
+./sanity.types.ts
+./schema.json
diff --git a/apps/web/README.md b/apps/web/README.md
deleted file mode 100644
index 27815d7ac..000000000
--- a/apps/web/README.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# CodingCat.dev — Astro 6 Migration
-
-Fresh Astro 6 project replacing the Next.js site. Deployed to Cloudflare Workers.
-
-## Stack
-
-- **Framework:** Astro 6 (SSR mode)
-- **Deployment:** Cloudflare Workers via `@astrojs/cloudflare`
-- **CMS:** Sanity via `@sanity/astro`
-- **Auth:** better-auth + Drizzle + Cloudflare D1
-- **Search:** Sanity Dataset Embeddings
-- **Styling:** Tailwind CSS v4 + shadcn/ui
-- **Interactive Islands:** React via `@astrojs/react`
-
-## Getting Started
-
-From repo root:
-
-```bash
-pnpm install
-pnpm --filter @codingcatdev/web dev
-```
-
-Or from `apps/web`:
-
-```bash
-pnpm install && pnpm dev
-```
-
-For Cloudflare Workers preview: `pnpm preview` (from `apps/web`).
-
-## Environment Variables
-
-- **Build:** Copy `.env.example` to `.env` or `.env.local`. Sanity project/dataset have defaults; optional vars are commented.
-- **Runtime (local):** Copy `.dev.vars.example` to `.dev.vars` and set secrets (Sanity token, better-auth, Google OAuth). In production, set these via `wrangler secret put `.
-
-## Deployment
-
-```bash
-npm run deploy
-```
-
-Requires `wrangler` CLI authenticated with Cloudflare.
diff --git a/apps/web/app/(dashboard)/dashboard/actions.ts b/apps/web/app/(dashboard)/dashboard/actions.ts
new file mode 100644
index 000000000..a06586c9a
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/actions.ts
@@ -0,0 +1,10 @@
+"use server";
+
+import { redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+
+export async function signOut() {
+ const supabase = await createClient();
+ await supabase.auth.signOut();
+ redirect("/dashboard/login");
+}
diff --git a/apps/web/app/(dashboard)/dashboard/auth/callback/route.ts b/apps/web/app/(dashboard)/dashboard/auth/callback/route.ts
new file mode 100644
index 000000000..e64179f82
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/auth/callback/route.ts
@@ -0,0 +1,20 @@
+import { createClient } from "@/lib/supabase/server";
+import { NextResponse } from "next/server";
+
+export async function GET(request: Request) {
+ const { searchParams, origin } = new URL(request.url);
+ const code = searchParams.get("code");
+ const next = searchParams.get("next") ?? "/dashboard";
+
+ if (code) {
+ const supabase = await createClient();
+ const { error } = await supabase.auth.exchangeCodeForSession(code);
+ if (!error) {
+ return NextResponse.redirect(`${origin}${next}`);
+ }
+ }
+
+ return NextResponse.redirect(
+ `${origin}/dashboard/login?error=Could+not+authenticate`,
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/config/config-form.tsx b/apps/web/app/(dashboard)/dashboard/config/config-form.tsx
new file mode 100644
index 000000000..01e50f024
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/config/config-form.tsx
@@ -0,0 +1,597 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { Loader2, Save, Plus, Trash2, X } from "lucide-react";
+import type { EngineConfig } from "@/lib/types/engine-config";
+
+const ALL_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+
+interface ConfigFormProps {
+ initialConfig: EngineConfig;
+}
+
+export function ConfigForm({ initialConfig }: ConfigFormProps) {
+ const [config, setConfig] = useState(initialConfig);
+ const [saving, setSaving] = useState(false);
+ const [newCategory, setNewCategory] = useState("");
+
+ const update = (key: K, value: EngineConfig[K]) => {
+ setConfig((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const res = await fetch("/api/dashboard/config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ });
+ if (res.ok) {
+ toast.success("Config saved. Changes propagate within 5 minutes.");
+ } else {
+ const data = await res.json();
+ toast.error(data.error || "Failed to save config");
+ }
+ } catch {
+ toast.error("Failed to save config");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const toggleDay = (day: string) => {
+ const days = config.publishDays || [];
+ update(
+ "publishDays",
+ days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
+ );
+ };
+
+ const addCategory = () => {
+ const trimmed = newCategory.trim();
+ if (!trimmed || (config.contentCategories || []).includes(trimmed)) return;
+ update("contentCategories", [...(config.contentCategories || []), trimmed]);
+ setNewCategory("");
+ };
+
+ const removeCategory = (cat: string) => {
+ update(
+ "contentCategories",
+ (config.contentCategories || []).filter((c) => c !== cat)
+ );
+ };
+
+ const updateTier = (
+ index: number,
+ field: "name" | "description" | "price",
+ value: string | number
+ ) => {
+ const tiers = [...(config.rateCardTiers || [])];
+ tiers[index] = { ...tiers[index], [field]: value };
+ update("rateCardTiers", tiers);
+ };
+
+ const addTier = () => {
+ update("rateCardTiers", [
+ ...(config.rateCardTiers || []),
+ { name: "", description: "", price: 0 },
+ ]);
+ };
+
+ const removeTier = (index: number) => {
+ update(
+ "rateCardTiers",
+ (config.rateCardTiers || []).filter((_, i) => i !== index)
+ );
+ };
+
+ return (
+
+ {/* Pipeline Control */}
+
+
+ Pipeline Control
+
+ Control auto-publishing, quality gates, and pipeline limits.
+
+
+
+
+
+
+
+
+
+
+ update("qualityThreshold", Number(e.target.value))
+ }
+ className="w-full accent-primary"
+ />
+
+
+
+
+
+ {/* Content Cadence */}
+
+
+ Content Cadence
+
+ How much content to produce and when to publish.
+
+
+
+
+
+
+
+
+ {ALL_DAYS.map((day) => (
+ toggleDay(day)}
+ >
+ {day}
+
+ ))}
+
+
+
+
+
+
+ {(config.contentCategories || []).map((cat) => (
+
+ {cat}
+
+
+ ))}
+
+
+
setNewCategory(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ addCategory();
+ }
+ }}
+ className="flex-1"
+ />
+
+
+
+
+
+
+ {/* AI & Generation */}
+
+
+ AI & Generation
+
+ Model selection and system instructions for content generation.
+
+
+
+
+
+
+
+
+
+
+ {/* Distribution */}
+
+
+ Distribution
+
+ Platform toggles and publishing settings.
+
+
+
+
+ {(
+ [
+ ["youtubeEnabled", "YouTube"],
+ ["twitterEnabled", "Twitter/X"],
+ ["linkedinEnabled", "LinkedIn"],
+ ["tiktokEnabled", "TikTok"],
+ ["instagramEnabled", "Instagram"],
+ ["blueskyEnabled", "Bluesky"],
+ ] as const
+ ).map(([key, label]) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ update(
+ "notificationEmails",
+ e.target.value
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ )
+ }
+ placeholder="admin@codingcat.dev"
+ />
+
+
+
+
+ {/* Sponsor */}
+
+
+ Sponsor Settings
+
+ Sponsor outreach and rate card configuration.
+
+
+
+
+
+
+
+ {(config.rateCardTiers || []).map((tier, index) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* Brand */}
+
+
+ Brand Colors
+
+ Colors used in generated infographics and video overlays.
+
+
+
+
+
+
+
+ {/* Save Button */}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/config/page.tsx b/apps/web/app/(dashboard)/dashboard/config/page.tsx
new file mode 100644
index 000000000..ac71c085a
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/config/page.tsx
@@ -0,0 +1,63 @@
+import { Suspense } from "react";
+import { connection } from "next/server";
+import { getEngineConfig } from "@/lib/config";
+import { ConfigForm } from "./config-form";
+
+export default function ConfigPage() {
+ return (
+
+
+
Engine Config
+
+ Configure the automated content engine. Changes propagate within 5 minutes.
+
+
+
+
+ Loading configuration...
+
+ }
+ >
+
+
+
+ );
+}
+
+async function ConfigContent() {
+ // The dashboard is auth-gated and renders live config. getEngineConfig uses an
+ // in-memory TTL (Date.now), so this must run dynamically, not at prerender.
+ await connection();
+
+ let config = null;
+ let error = null;
+
+ try {
+ config = await getEngineConfig();
+ } catch (err) {
+ error = err instanceof Error ? err.message : "Failed to load config";
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+ Make sure the engineConfig singleton exists in Sanity Studio.
+
+
+ );
+ }
+
+ if (config) {
+ return ;
+ }
+
+ return (
+
+
Loading configuration...
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/content/actions.ts b/apps/web/app/(dashboard)/dashboard/content/actions.ts
new file mode 100644
index 000000000..f107c9d51
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/content/actions.ts
@@ -0,0 +1,16 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { dashboardClient } from "@/lib/sanity/dashboard";
+
+export async function approveIdea(id: string) {
+ if (!dashboardClient) throw new Error("Sanity client not available");
+ await dashboardClient.patch(id).set({ status: "approved" }).commit();
+ revalidatePath("/dashboard/content");
+}
+
+export async function rejectIdea(id: string) {
+ if (!dashboardClient) throw new Error("Sanity client not available");
+ await dashboardClient.patch(id).set({ status: "rejected" }).commit();
+ revalidatePath("/dashboard/content");
+}
diff --git a/apps/web/app/(dashboard)/dashboard/content/content-ideas-table.tsx b/apps/web/app/(dashboard)/dashboard/content/content-ideas-table.tsx
new file mode 100644
index 000000000..1f1d3a4b6
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/content/content-ideas-table.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Lightbulb, ExternalLink } from "@/components/icons";
+import { approveIdea, rejectIdea } from "./actions";
+
+interface ContentIdea {
+ _id: string;
+ _createdAt: string;
+ title: string;
+ status: "new" | "approved" | "rejected";
+ sourceUrl?: string;
+ summary?: string;
+ topics?: string[];
+ collectedAt?: string;
+}
+
+const statusConfig: Record = {
+ new: {
+ label: "New",
+ className:
+ "bg-blue-100 text-blue-800 hover:bg-blue-100 dark:bg-blue-900 dark:text-blue-300",
+ },
+ approved: {
+ label: "Approved",
+ className:
+ "bg-green-100 text-green-800 hover:bg-green-100 dark:bg-green-900 dark:text-green-300",
+ },
+ rejected: {
+ label: "Rejected",
+ className:
+ "bg-red-100 text-red-800 hover:bg-red-100 dark:bg-red-900 dark:text-red-300",
+ },
+};
+
+function StatusBadge({ status }: { status: string }) {
+ const config = statusConfig[status] ?? { label: status, className: "" };
+ return (
+
+ {config.label}
+
+ );
+}
+
+function formatDate(dateString: string) {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+function truncateUrl(url: string, maxLength = 30) {
+ try {
+ const parsed = new URL(url);
+ const display = parsed.hostname + parsed.pathname;
+ return display.length > maxLength
+ ? display.slice(0, maxLength) + "\u2026"
+ : display;
+ } catch {
+ return url.length > maxLength ? url.slice(0, maxLength) + "\u2026" : url;
+ }
+}
+
+export function ContentIdeasTable({ ideas }: { ideas: ContentIdea[] }) {
+ if (ideas.length === 0) {
+ return (
+
+
+
No content ideas yet
+
+ Content ideas will appear here once the ideation cron starts
+ running.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Title
+ Status
+ Source
+ Topics
+ Collected
+ Actions
+
+
+
+ {ideas.map((idea) => (
+
+
+ {idea.title || "Untitled"}
+
+
+
+
+
+ {idea.sourceUrl ? (
+
+ {truncateUrl(idea.sourceUrl)}
+
+
+ ) : (
+ \u2014
+ )}
+
+
+ {idea.topics && idea.topics.length > 0 ? (
+
+ {idea.topics.slice(0, 3).map((topic) => (
+
+ {topic}
+
+ ))}
+ {idea.topics.length > 3 && (
+
+ +{idea.topics.length - 3}
+
+ )}
+
+ ) : (
+ \u2014
+ )}
+
+
+ {formatDate(idea.collectedAt || idea._createdAt)}
+
+
+ {idea.status === "new" && (
+
+
+
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/content/page.tsx b/apps/web/app/(dashboard)/dashboard/content/page.tsx
new file mode 100644
index 000000000..7b2ed5dc4
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/content/page.tsx
@@ -0,0 +1,68 @@
+import { Suspense } from "react";
+import { connection } from "next/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import { ContentIdeasTable } from "./content-ideas-table";
+import { PageRefreshButton } from "@/components/page-refresh-button";
+
+interface ContentIdea {
+ _id: string;
+ _createdAt: string;
+ title: string;
+ status: "new" | "approved" | "rejected";
+ sourceUrl?: string;
+ summary?: string;
+ topics?: string[];
+ collectedAt?: string;
+}
+
+const CONTENT_IDEAS_QUERY = `*[_type == "contentIdea"] | order(_createdAt desc) {
+ _id,
+ _createdAt,
+ title,
+ status,
+ sourceUrl,
+ summary,
+ topics,
+ collectedAt
+}`;
+
+export default function ContentPage() {
+ return (
+
+
+
+
+ Content Ideas
+
+
+ Manage content ideas — approve, reject, or review incoming
+ topics.
+
+
+
+
+
+
Loading content ideas...
+ }
+ >
+
+
+
+ );
+}
+
+async function ContentIdeasContent() {
+ await connection();
+
+ let ideas: ContentIdea[] = [];
+
+ try {
+ ideas = await dashboardQuery(CONTENT_IDEAS_QUERY);
+ } catch (error) {
+ console.error("Failed to fetch content ideas:", error);
+ }
+
+ return ;
+}
diff --git a/apps/web/app/(dashboard)/dashboard/login/page.tsx b/apps/web/app/(dashboard)/dashboard/login/page.tsx
new file mode 100644
index 000000000..2e37f7140
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/login/page.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { createClient } from "@/lib/supabase/client";
+import { useSearchParams } from "next/navigation";
+import { Suspense } from "react";
+
+function GoogleIcon() {
+ return (
+
+ );
+}
+
+function LoginForm() {
+ const searchParams = useSearchParams();
+ const error = searchParams.get("error");
+
+ const handleGoogleSignIn = async () => {
+ const supabase = createClient();
+ await supabase.auth.signInWithOAuth({
+ provider: "google",
+ options: {
+ redirectTo: `${window.location.origin}/dashboard/auth/callback`,
+ },
+ });
+ };
+
+ return (
+
+
+
+ CodingCat.dev
+
+ Sign in to the Content Ops Dashboard
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ Access restricted to authorized accounts only.
+
+
+
+
+
+ );
+}
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx
new file mode 100644
index 000000000..95f8c732e
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/page.tsx
@@ -0,0 +1,44 @@
+import { Suspense } from "react";
+import { SectionCardsLive } from "@/components/section-cards-live";
+import { RecentActivity } from "@/components/recent-activity";
+import { PipelineStatus } from "@/components/pipeline-status";
+
+export default function DashboardPage() {
+ return (
+
+
+
+ Content Ops Dashboard
+
+
+ Overview of your automated content engine — videos, sponsors,
+ and pipeline health.
+
+
+
+ {/* These live components read the current time on the client, so they
+ must sit under a Suspense boundary to be treated as dynamic holes
+ under Cache Components (they can't be prerendered statically). */}
+
}>
+
+
+
+
+
}>
+
+
+
+
Pipeline Status
+
+ Real-time view of content moving through the pipeline.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/pipeline/page.tsx b/apps/web/app/(dashboard)/dashboard/pipeline/page.tsx
new file mode 100644
index 000000000..98db3e7f3
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/pipeline/page.tsx
@@ -0,0 +1,218 @@
+import { Suspense } from "react";
+import { connection } from "next/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+
+const STATUS_LABELS: Record = {
+ draft: { label: "Draft", color: "bg-gray-500" },
+ researching: { label: "Researching", color: "bg-blue-500" },
+ research_complete: { label: "Research Complete", color: "bg-blue-600" },
+ scripting: { label: "Scripting", color: "bg-indigo-500" },
+ script_complete: { label: "Script Complete", color: "bg-indigo-600" },
+ generating_images: { label: "Generating Images", color: "bg-purple-500" },
+ images_complete: { label: "Images Complete", color: "bg-purple-600" },
+ generating_audio: { label: "Generating Audio", color: "bg-pink-500" },
+ video_gen: { label: "Video Generation", color: "bg-orange-500" },
+ pending_review: { label: "Pending Review", color: "bg-yellow-500" },
+ approved: { label: "Approved", color: "bg-green-500" },
+ published: { label: "Published", color: "bg-green-700" },
+ rejected: { label: "Rejected", color: "bg-red-500" },
+ failed: { label: "Failed", color: "bg-red-700" },
+};
+
+const ALL_STATUSES = Object.keys(STATUS_LABELS);
+
+const IN_PROGRESS_STATUSES = [
+ "researching",
+ "scripting",
+ "generating_images",
+ "generating_audio",
+ "video_gen",
+];
+
+interface PipelineVideo {
+ _id: string;
+ title: string;
+ status: string;
+ _updatedAt: string;
+}
+
+export default function PipelinePage() {
+ return (
+
+
+
Pipeline Status
+
+
Loading pipeline...
+ }
+ >
+
+
+
+ );
+}
+
+async function PipelineContent() {
+ await connection();
+
+ // Fetch counts for all statuses in a single query
+ const counts = await dashboardQuery>(
+ `{
+ ${ALL_STATUSES.map(
+ (s) =>
+ `"${s}": count(*[_type == "automatedVideo" && status == "${s}"])`
+ ).join(",\n ")}
+ }`
+ );
+
+ // Fetch active workflows (in-progress videos)
+ const activeVideos = await dashboardQuery(
+ `*[_type == "automatedVideo" && status in $statuses] | order(_updatedAt desc) [0..19] {
+ _id, title, status, _updatedAt
+ }`,
+ { statuses: IN_PROGRESS_STATUSES }
+ );
+
+ // Fetch recent completions and failures
+ const recentCompleted = await dashboardQuery(
+ `*[_type == "automatedVideo" && status in ["published", "approved", "rejected", "failed"]] | order(_updatedAt desc) [0..9] {
+ _id, title, status, _updatedAt
+ }`
+ );
+
+ const totalVideos = Object.values(counts || {}).reduce(
+ (sum, count) => sum + (count || 0),
+ 0
+ );
+
+ return (
+ <>
+
+ Overview of {totalVideos} videos across all pipeline stages.
+
+
+ {/* Status Count Cards */}
+
+ {ALL_STATUSES.map((status) => {
+ const info = STATUS_LABELS[status];
+ const count = counts?.[status] ?? 0;
+ return (
+
+
+
+
+
+ {info.label}
+
+
+ {count}
+
+
+ );
+ })}
+
+
+
+ {/* Active Workflows */}
+
+
+ Active Workflows
+
+
+ {activeVideos.length === 0 ? (
+
+ No videos currently in progress.
+
+ ) : (
+
+ {activeVideos.map((video) => {
+ const info = STATUS_LABELS[video.status] || {
+ label: video.status,
+ color: "bg-gray-500",
+ };
+ return (
+
+
+
+
+ {video.title || "Untitled"}
+
+
+ {info.label} •{" "}
+ {new Date(video._updatedAt).toLocaleString()}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Recent Completions / Failures */}
+
+
+ Recent Completions & Failures
+
+
+ {recentCompleted.length === 0 ? (
+
+ No recent completions or failures.
+
+ ) : (
+
+ {recentCompleted.map((video) => {
+ const info = STATUS_LABELS[video.status] || {
+ label: video.status,
+ color: "bg-gray-500",
+ };
+ return (
+
+
+ {info.label}
+
+
+
+ {video.title || "Untitled"}
+
+
+ {new Date(video._updatedAt).toLocaleString()}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/review/[id]/page.tsx b/apps/web/app/(dashboard)/dashboard/review/[id]/page.tsx
new file mode 100644
index 000000000..3211afcd3
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/review/[id]/page.tsx
@@ -0,0 +1,61 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft } from "lucide-react";
+import { ReviewDetailClient } from "./review-detail-client";
+
+interface Props {
+ params: Promise<{ id: string }>;
+}
+
+export default function ReviewDetailPage({ params }: Props) {
+ return (
+
+
+
+
+
+
+
Loading video...
+ }
+ >
+
+
+
+ );
+}
+
+async function ReviewDetailContent({ params }: Props) {
+ const { id } = await params;
+
+ const video = await dashboardQuery(
+ `*[_type == "automatedVideo" && _id == $id][0] {
+ _id,
+ title,
+ qualityScore,
+ qualityIssues,
+ status,
+ _updatedAt,
+ script,
+ "infographicsHorizontal": infographicsHorizontal[] {
+ _key,
+ "asset": asset-> { url }
+ }
+ }`,
+ { id }
+ );
+
+ if (!video) {
+ notFound();
+ }
+
+ // biome-ignore lint/suspicious/noExplicitAny: video shape comes from a loose GROQ projection
+ return ;
+}
diff --git a/apps/web/app/(dashboard)/dashboard/review/[id]/review-detail-client.tsx b/apps/web/app/(dashboard)/dashboard/review/[id]/review-detail-client.tsx
new file mode 100644
index 000000000..ff4649379
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/review/[id]/review-detail-client.tsx
@@ -0,0 +1,305 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Check, X, Pencil, Loader2 } from "lucide-react";
+import { approveVideo, rejectVideo, updateVideoField } from "../actions";
+
+interface Scene {
+ narration?: string;
+ sceneTitle?: string;
+ imagePrompt?: string;
+}
+
+interface VideoData {
+ _id: string;
+ title: string;
+ qualityScore: number | null;
+ qualityIssues: string[] | null;
+ status: string;
+ script?: {
+ hook?: string;
+ cta?: string;
+ scenes?: Scene[];
+ };
+ infographicsHorizontal?: Array<{
+ asset?: { url?: string };
+ _key?: string;
+ }>;
+}
+
+export function ReviewDetailClient({ video }: { video: VideoData }) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [editingTitle, setEditingTitle] = useState(false);
+ const [titleValue, setTitleValue] = useState(video.title || "");
+ const [rejectReason, setRejectReason] = useState("");
+ const [showRejectForm, setShowRejectForm] = useState(false);
+
+ const handleApprove = () => {
+ startTransition(async () => {
+ try {
+ await approveVideo(video._id);
+ toast.success("Video approved! It will proceed to publishing.");
+ router.push("/dashboard/review");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to approve");
+ }
+ });
+ };
+
+ const handleReject = () => {
+ if (!rejectReason.trim()) {
+ toast.error("Please provide a reason for rejection");
+ return;
+ }
+ startTransition(async () => {
+ try {
+ await rejectVideo(video._id, rejectReason);
+ toast.success("Video rejected.");
+ router.push("/dashboard/review");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to reject");
+ }
+ });
+ };
+
+ const handleSaveTitle = () => {
+ startTransition(async () => {
+ try {
+ await updateVideoField(video._id, "title", titleValue);
+ setEditingTitle(false);
+ toast.success("Title updated");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to update title");
+ }
+ });
+ };
+
+ return (
+
+ {/* Title Section */}
+
+ {editingTitle ? (
+
+ setTitleValue(e.target.value)}
+ className="text-2xl font-bold"
+ autoFocus
+ />
+
+
+
+ ) : (
+
+
+ {titleValue || "Untitled Video"}
+
+
+
+ )}
+
+ {video.status}
+
+
+
+ {/* Quality Score Breakdown */}
+
+
+ Quality Score
+
+
+
+
+ {video.qualityScore ?? "—"}
+
+
/ 100
+
+ {video.qualityIssues && video.qualityIssues.length > 0 && (
+
+
Issues Found:
+
+ {video.qualityIssues.map((issue, i) => (
+ -
+ •
+ {issue}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Infographic Gallery */}
+ {video.infographicsHorizontal && video.infographicsHorizontal.length > 0 && (
+
+
+ Infographics ({video.infographicsHorizontal.length})
+
+
+
+ {video.infographicsHorizontal.map((img, i) => (
+
+ {img.asset?.url ? (
+

+ ) : (
+
+ No image
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ {/* Script Scenes */}
+ {video.script?.scenes && video.script.scenes.length > 0 && (
+
+
+ Script ({video.script.scenes.length} scenes)
+
+
+ {video.script.hook && (
+
+
Hook
+
{video.script.hook}
+
+ )}
+ {video.script.scenes.map((scene, i) => (
+
+
+ Scene {i + 1}{scene.sceneTitle ? `: ${scene.sceneTitle}` : ""}
+
+
+ {scene.narration || "No narration"}
+
+
+ ))}
+ {video.script.cta && (
+
+
CTA
+
{video.script.cta}
+
+ )}
+
+
+ )}
+
+ {/* Approve / Reject Actions */}
+
+
+ Review Decision
+
+
+ {showRejectForm ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/review/actions.ts b/apps/web/app/(dashboard)/dashboard/review/actions.ts
new file mode 100644
index 000000000..4cf0ced25
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/review/actions.ts
@@ -0,0 +1,102 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { writeClient } from "@/lib/sanity-write-client";
+import { createClient } from "@/lib/supabase/server";
+
+const ALLOWED_FIELDS = ["title", "script.hook", "script.cta"] as const;
+type AllowedField = (typeof ALLOWED_FIELDS)[number];
+
+/**
+ * Verify the caller is authenticated. Fail closed — throws if auth
+ * is not configured or user is not logged in.
+ */
+async function requireAuth(): Promise {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
+ process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ throw new Error("Auth not configured");
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ throw new Error("Unauthorized — please sign in");
+ }
+
+ return user.email ?? "dashboard-user";
+}
+
+export async function approveVideo(id: string) {
+ const userEmail = await requireAuth();
+
+ if (!id || typeof id !== "string") {
+ throw new Error("Invalid video ID");
+ }
+
+ await writeClient
+ .patch(id)
+ .set({
+ status: "approved",
+ reviewedAt: new Date().toISOString(),
+ reviewedBy: userEmail,
+ })
+ .commit();
+
+ revalidatePath("/dashboard/review");
+ revalidatePath(`/dashboard/review/${id}`);
+}
+
+export async function rejectVideo(id: string, reason: string) {
+ const userEmail = await requireAuth();
+
+ if (!id || typeof id !== "string") {
+ throw new Error("Invalid video ID");
+ }
+ if (!reason || typeof reason !== "string" || reason.trim().length === 0) {
+ throw new Error("Rejection reason is required");
+ }
+
+ await writeClient
+ .patch(id)
+ .set({
+ status: "rejected",
+ flaggedReason: reason.trim(),
+ reviewedAt: new Date().toISOString(),
+ reviewedBy: userEmail,
+ })
+ .commit();
+
+ revalidatePath("/dashboard/review");
+ revalidatePath(`/dashboard/review/${id}`);
+}
+
+export async function updateVideoField(
+ id: string,
+ field: string,
+ value: string,
+) {
+ await requireAuth();
+
+ if (!id || typeof id !== "string") {
+ throw new Error("Invalid video ID");
+ }
+ if (!ALLOWED_FIELDS.includes(field as AllowedField)) {
+ throw new Error(
+ `Field "${field}" is not editable. Allowed: ${ALLOWED_FIELDS.join(", ")}`,
+ );
+ }
+ if (typeof value !== "string") {
+ throw new Error("Value must be a string");
+ }
+
+ await writeClient.patch(id).set({ [field]: value }).commit();
+
+ revalidatePath(`/dashboard/review/${id}`);
+}
diff --git a/apps/web/app/(dashboard)/dashboard/review/page.tsx b/apps/web/app/(dashboard)/dashboard/review/page.tsx
new file mode 100644
index 000000000..963bba26f
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/review/page.tsx
@@ -0,0 +1,125 @@
+import { Suspense } from "react";
+import Link from "next/link";
+import { connection } from "next/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+
+interface ReviewVideo {
+ _id: string;
+ title: string;
+ qualityScore: number | null;
+ qualityIssues: string[] | null;
+ status: string;
+ _updatedAt: string;
+ thumbnailUrl: string | null;
+ scriptQualityScore: number | null;
+}
+
+function QualityBadge({ score }: { score: number | null }) {
+ if (score === null || score === undefined) {
+ return No score;
+ }
+ if (score >= 80) return {score};
+ if (score >= 60) return {score};
+ return {score};
+}
+
+export default function ReviewQueuePage() {
+ return (
+
+
+
Review Queue
+
+ Videos awaiting your review before publishing.
+
+
+
+
Loading review queue...
+ }
+ >
+
+
+
+ );
+}
+
+async function ReviewQueueContent() {
+ await connection();
+
+ const videos = await dashboardQuery(
+ `*[_type == "automatedVideo" && status == "pending_review"] | order(_updatedAt desc) [0..49] {
+ _id, title, qualityScore, qualityIssues, status, _updatedAt,
+ "thumbnailUrl": thumbnailHorizontal.asset->url,
+ scriptQualityScore
+ }`
+ );
+
+ return (
+ <>
+ {videos.length === 0 ? (
+
+
+
+ No videos pending review
+
+
+ Videos will appear here when they pass quality checks.
+
+
+
+ ) : (
+
+ {videos.map((video) => (
+
+
+ {video.thumbnailUrl && (
+
+

+
+ )}
+
+
+ {video.title || "Untitled Video"}
+
+
+
+ Pending Review
+
+ {video.scriptQualityScore !== null && video.scriptQualityScore !== undefined && (
+
+ Script: {video.scriptQualityScore}
+
+ )}
+ {video.qualityIssues && video.qualityIssues.length > 0 && (
+
+ {video.qualityIssues.length} issue{video.qualityIssues.length !== 1 ? "s" : ""}
+
+ )}
+
+ {new Date(video._updatedAt).toLocaleDateString()}
+
+
+
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/settings/page.tsx b/apps/web/app/(dashboard)/dashboard/settings/page.tsx
new file mode 100644
index 000000000..4094974b3
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/settings/page.tsx
@@ -0,0 +1,115 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { SettingsForm } from "./settings-form";
+
+const INTEGRATIONS = [
+ {
+ name: "Sanity",
+ envVar: "SANITY_API_TOKEN",
+ description: "Content management and data store",
+ },
+ {
+ name: "YouTube",
+ envVar: "YOUTUBE_API_KEY",
+ description: "Video publishing and analytics",
+ },
+ {
+ name: "Supabase",
+ envVar: "NEXT_PUBLIC_SUPABASE_URL",
+ description: "Authentication and database",
+ },
+ {
+ name: "ElevenLabs",
+ envVar: "ELEVENLABS_API_KEY",
+ description: "AI voice generation for videos",
+ },
+ {
+ name: "Gemini",
+ envVar: "GEMINI_API_KEY",
+ description: "Script generation and content AI",
+ },
+ {
+ name: "Stripe",
+ envVar: "STRIPE_SECRET_KEY",
+ description: "Sponsor payment processing",
+ },
+ {
+ name: "Resend",
+ envVar: "RESEND_API_KEY",
+ description: "Transactional email delivery",
+ },
+];
+
+function IntegrationDot({ connected }: { connected: boolean }) {
+ return (
+
+ );
+}
+
+export default function SettingsPage() {
+ // Check which env vars are likely set (server component has access)
+ const integrationStatus = INTEGRATIONS.map((integration) => ({
+ ...integration,
+ connected: !!process.env[integration.envVar],
+ }));
+
+ return (
+
+
+
Settings
+
+ Configure your content engine — cadence, categories, and rate card.
+
+
+
+
+
+ {/* Integrations Status — server-rendered */}
+
+
+ Integrations Status
+
+ Connection status for external services. Green indicates the
+ environment variable is configured.
+
+
+
+
+ {integrationStatus.map((integration) => (
+
+
+
+
{integration.name}
+
+ {integration.description}
+
+
+
+ {integration.connected ? "Connected" : "Not configured"}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/settings/settings-form.tsx b/apps/web/app/(dashboard)/dashboard/settings/settings-form.tsx
new file mode 100644
index 000000000..74c4d4a71
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/settings/settings-form.tsx
@@ -0,0 +1,370 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Loader2, Plus, Save, Trash2, X } from "lucide-react";
+
+interface RateCardTier {
+ name: string;
+ description: string;
+ price: number;
+}
+
+interface DashboardSettings {
+ videosPerWeek: number;
+ publishDays: string[];
+ contentCategories: string[];
+ rateCardTiers: RateCardTier[];
+}
+
+const ALL_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+
+const DEFAULT_SETTINGS: DashboardSettings = {
+ videosPerWeek: 3,
+ publishDays: ["Mon", "Wed", "Fri"],
+ contentCategories: [
+ "JavaScript", "TypeScript", "React", "Next.js", "Angular",
+ "Svelte", "Node.js", "CSS", "DevOps", "AI / ML",
+ "Web Performance", "Tooling",
+ ],
+ rateCardTiers: [
+ { name: "Pre-roll Mention", description: "15-second sponsor mention at the start of the video", price: 200 },
+ { name: "Mid-roll Segment", description: "60-second dedicated sponsor segment mid-video", price: 500 },
+ { name: "Dedicated Video", description: "Full sponsored video with product deep-dive", price: 1500 },
+ ],
+};
+
+export function SettingsForm() {
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [newCategory, setNewCategory] = useState("");
+
+ const fetchSettings = useCallback(async () => {
+ try {
+ const res = await fetch("/api/dashboard/settings");
+ if (res.ok) {
+ const data = await res.json();
+ setSettings({
+ videosPerWeek: data.videosPerWeek ?? DEFAULT_SETTINGS.videosPerWeek,
+ publishDays: data.publishDays ?? DEFAULT_SETTINGS.publishDays,
+ contentCategories: data.contentCategories ?? DEFAULT_SETTINGS.contentCategories,
+ rateCardTiers: data.rateCardTiers ?? DEFAULT_SETTINGS.rateCardTiers,
+ });
+ }
+ } catch {
+ // Use defaults on error
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchSettings();
+ }, [fetchSettings]);
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const res = await fetch("/api/dashboard/settings", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(settings),
+ });
+ if (res.ok) {
+ toast.success("Settings saved successfully");
+ } else {
+ const data = await res.json();
+ toast.error(data.error || "Failed to save settings");
+ }
+ } catch {
+ toast.error("Failed to save settings");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const toggleDay = (day: string) => {
+ setSettings((prev) => ({
+ ...prev,
+ publishDays: prev.publishDays.includes(day)
+ ? prev.publishDays.filter((d) => d !== day)
+ : [...prev.publishDays, day],
+ }));
+ };
+
+ const addCategory = () => {
+ const trimmed = newCategory.trim();
+ if (!trimmed || settings.contentCategories.includes(trimmed)) return;
+ setSettings((prev) => ({
+ ...prev,
+ contentCategories: [...prev.contentCategories, trimmed],
+ }));
+ setNewCategory("");
+ };
+
+ const removeCategory = (category: string) => {
+ setSettings((prev) => ({
+ ...prev,
+ contentCategories: prev.contentCategories.filter((c) => c !== category),
+ }));
+ };
+
+ const updateTier = (index: number, field: keyof RateCardTier, value: string | number) => {
+ setSettings((prev) => ({
+ ...prev,
+ rateCardTiers: prev.rateCardTiers.map((tier, i) =>
+ i === index ? { ...tier, [field]: value } : tier
+ ),
+ }));
+ };
+
+ const addTier = () => {
+ setSettings((prev) => ({
+ ...prev,
+ rateCardTiers: [...prev.rateCardTiers, { name: "", description: "", price: 0 }],
+ }));
+ };
+
+ const removeTier = (index: number) => {
+ setSettings((prev) => ({
+ ...prev,
+ rateCardTiers: prev.rateCardTiers.filter((_, i) => i !== index),
+ }));
+ };
+
+ if (loading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+ <>
+
+ {/* Publishing Cadence */}
+
+
+ Publishing Cadence
+
+ Control how often videos are published and on which days.
+
+
+
+
+
+
+ setSettings((prev) => ({
+ ...prev,
+ videosPerWeek: Number.parseInt(e.target.value, 10) || 1,
+ }))
+ }
+ className="w-24"
+ />
+
+
+
+
+
+ {ALL_DAYS.map((day) => (
+ toggleDay(day)}
+ >
+ {day}
+
+ ))}
+
+
+
+
+
+ {/* Content Categories */}
+
+
+ Content Categories
+
+ Categories used for content idea classification and YouTube
+ metadata.
+
+
+
+
+ {settings.contentCategories.map((category) => (
+
+ {category}
+
+
+ ))}
+
+
+
setNewCategory(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ addCategory();
+ }
+ }}
+ className="flex-1"
+ />
+
+
+
+
+
+ {/* Sponsor Rate Card */}
+
+
+ Sponsor Rate Card
+
+ Sponsorship tiers and pricing used by the sponsor portal and
+ pipeline.
+
+
+
+
+ {settings.rateCardTiers.map((tier, index) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/sponsors/actions.ts b/apps/web/app/(dashboard)/dashboard/sponsors/actions.ts
new file mode 100644
index 000000000..d2f400fd6
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/sponsors/actions.ts
@@ -0,0 +1,10 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { dashboardClient } from "@/lib/sanity/dashboard";
+
+export async function updateLeadStatus(id: string, status: string) {
+ if (!dashboardClient) throw new Error("Sanity client not available");
+ await dashboardClient.patch(id).set({ status }).commit();
+ revalidatePath("/dashboard/sponsors");
+}
diff --git a/apps/web/app/(dashboard)/dashboard/sponsors/page.tsx b/apps/web/app/(dashboard)/dashboard/sponsors/page.tsx
new file mode 100644
index 000000000..8f1730a8c
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/sponsors/page.tsx
@@ -0,0 +1,123 @@
+import { Suspense } from "react";
+import { connection } from "next/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { SponsorLeadsTable } from "./sponsor-leads-table";
+import { SponsorPoolTable } from "./sponsor-pool-table";
+import { PageRefreshButton } from "@/components/page-refresh-button";
+
+interface SponsorLead {
+ _id: string;
+ _createdAt: string;
+ companyName: string;
+ contactName: string;
+ contactEmail: string;
+ source: "inbound" | "outbound";
+ status: "new" | "contacted" | "replied" | "negotiating" | "booked" | "paid";
+ intent?: string;
+ rateCard?: string;
+ bookedSlot?: { title: string };
+ lastEmailAt?: string;
+ notes?: string;
+}
+
+interface SponsorPool {
+ _id: string;
+ companyName: string;
+ contactName: string;
+ contactEmail: string;
+ website?: string;
+ category?: string;
+ source: "curated" | "enriched";
+ relevanceScore?: number;
+ lastContactedAt?: string;
+}
+
+const LEADS_QUERY = `*[_type == "sponsorLead"] | order(_createdAt desc) {
+ _id,
+ _createdAt,
+ companyName,
+ contactName,
+ contactEmail,
+ source,
+ status,
+ intent,
+ rateCard,
+ bookedSlot->{ title },
+ lastEmailAt,
+ notes
+}`;
+
+const POOL_QUERY = `*[_type == "sponsorPool" && optedOut != true] | order(relevanceScore desc) {
+ _id,
+ companyName,
+ contactName,
+ contactEmail,
+ website,
+ category,
+ source,
+ relevanceScore,
+ lastContactedAt
+}`;
+
+export default function SponsorsPage() {
+ return (
+
+
+
+
+ Sponsor Pipeline
+
+
+ Manage sponsor leads, track deals through the pipeline, and
+ browse the sponsor pool.
+
+
+
+
+
+
Loading sponsors...
+ }
+ >
+
+
+
+ );
+}
+
+async function SponsorsContent() {
+ await connection();
+
+ let leads: SponsorLead[] = [];
+ let pool: SponsorPool[] = [];
+
+ try {
+ [leads, pool] = await Promise.all([
+ dashboardQuery(LEADS_QUERY),
+ dashboardQuery(POOL_QUERY),
+ ]);
+ } catch (error) {
+ console.error("Failed to fetch sponsor data:", error);
+ }
+
+ return (
+
+
+
+ Pipeline{leads.length > 0 && ` (${leads.length})`}
+
+
+ Sponsor Pool{pool.length > 0 && ` (${pool.length})`}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/sponsors/sponsor-leads-table.tsx b/apps/web/app/(dashboard)/dashboard/sponsors/sponsor-leads-table.tsx
new file mode 100644
index 000000000..d00b0f3f3
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/sponsors/sponsor-leads-table.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Handshake } from "lucide-react";
+import { updateLeadStatus } from "./actions";
+
+interface SponsorLead {
+ _id: string;
+ _createdAt: string;
+ companyName: string;
+ contactName: string;
+ contactEmail: string;
+ source: "inbound" | "outbound";
+ status: "new" | "contacted" | "replied" | "negotiating" | "booked" | "paid";
+ intent?: string;
+ rateCard?: string;
+ bookedSlot?: { title: string };
+ lastEmailAt?: string;
+ notes?: string;
+}
+
+const statusConfig: Record = {
+ new: {
+ label: "New",
+ className:
+ "bg-blue-100 text-blue-800 hover:bg-blue-100 dark:bg-blue-900 dark:text-blue-300",
+ },
+ contacted: {
+ label: "Contacted",
+ className:
+ "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300",
+ },
+ replied: {
+ label: "Replied",
+ className:
+ "bg-orange-100 text-orange-800 hover:bg-orange-100 dark:bg-orange-900 dark:text-orange-300",
+ },
+ negotiating: {
+ label: "Negotiating",
+ className:
+ "bg-purple-100 text-purple-800 hover:bg-purple-100 dark:bg-purple-900 dark:text-purple-300",
+ },
+ booked: {
+ label: "Booked",
+ className:
+ "bg-green-100 text-green-800 hover:bg-green-100 dark:bg-green-900 dark:text-green-300",
+ },
+ paid: {
+ label: "Paid",
+ className:
+ "bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:bg-emerald-900 dark:text-emerald-300",
+ },
+};
+
+const sourceConfig: Record = {
+ inbound: {
+ label: "Inbound",
+ className:
+ "bg-teal-100 text-teal-800 hover:bg-teal-100 dark:bg-teal-900 dark:text-teal-300",
+ },
+ outbound: {
+ label: "Outbound",
+ className:
+ "bg-indigo-100 text-indigo-800 hover:bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-300",
+ },
+};
+
+const statusOptions = [
+ "new",
+ "contacted",
+ "replied",
+ "negotiating",
+ "booked",
+ "paid",
+] as const;
+
+function StatusBadge({ status }: { status: string }) {
+ const config = statusConfig[status] ?? { label: status, className: "" };
+ return (
+
+ {config.label}
+
+ );
+}
+
+function SourceBadge({ source }: { source: string }) {
+ const config = sourceConfig[source] ?? { label: source, className: "" };
+ return (
+
+ {config.label}
+
+ );
+}
+
+function formatDate(dateString: string) {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+export function SponsorLeadsTable({ leads }: { leads: SponsorLead[] }) {
+ if (leads.length === 0) {
+ return (
+
+
+
No sponsor leads yet
+
+ Sponsor leads will appear here once deals start coming in through
+ inbound or outbound channels.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Company
+ Contact
+ Source
+ Status
+ Booked Slot
+ Last Email
+ Actions
+
+
+
+ {leads.map((lead) => (
+
+
+ {lead.companyName || "Unknown"}
+
+
+
+ {lead.contactName || "\u2014"}
+ {lead.contactEmail && (
+
+ {lead.contactEmail}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {lead.bookedSlot?.title || "\u2014"}
+
+
+ {lead.lastEmailAt ? formatDate(lead.lastEmailAt) : "\u2014"}
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/sponsors/sponsor-pool-table.tsx b/apps/web/app/(dashboard)/dashboard/sponsors/sponsor-pool-table.tsx
new file mode 100644
index 000000000..c0f3f858b
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/sponsors/sponsor-pool-table.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { Users } from "lucide-react";
+
+interface SponsorPool {
+ _id: string;
+ companyName: string;
+ contactName: string;
+ contactEmail: string;
+ website?: string;
+ category?: string;
+ source: "curated" | "enriched";
+ relevanceScore?: number;
+ lastContactedAt?: string;
+}
+
+const sourceConfig: Record = {
+ curated: {
+ label: "Curated",
+ className:
+ "bg-teal-100 text-teal-800 hover:bg-teal-100 dark:bg-teal-900 dark:text-teal-300",
+ },
+ enriched: {
+ label: "Enriched",
+ className:
+ "bg-indigo-100 text-indigo-800 hover:bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-300",
+ },
+};
+
+function SourceBadge({ source }: { source: string }) {
+ const config = sourceConfig[source] ?? { label: source, className: "" };
+ return (
+
+ {config.label}
+
+ );
+}
+
+function formatDate(dateString: string) {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+export function SponsorPoolTable({ sponsors }: { sponsors: SponsorPool[] }) {
+ if (sponsors.length === 0) {
+ return (
+
+
+
No sponsors in pool
+
+ Potential sponsors will appear here once the pool is populated through
+ curation or enrichment.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Company
+ Contact
+ Category
+ Source
+ Score
+ Last Contacted
+
+
+
+ {sponsors.map((sponsor) => (
+
+
+
+
+
+
+ {sponsor.contactName || "\u2014"}
+ {sponsor.contactEmail && (
+
+ {sponsor.contactEmail}
+
+ )}
+
+
+
+ {sponsor.category || "\u2014"}
+
+
+
+
+
+ {sponsor.relevanceScore ?? "\u2014"}
+
+
+ {sponsor.lastContactedAt
+ ? formatDate(sponsor.lastContactedAt)
+ : "\u2014"}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/dashboard/videos/actions.ts b/apps/web/app/(dashboard)/dashboard/videos/actions.ts
new file mode 100644
index 000000000..795115090
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/videos/actions.ts
@@ -0,0 +1,26 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { dashboardClient } from "@/lib/sanity/dashboard";
+
+export async function regenerateScript(id: string) {
+ if (!dashboardClient) throw new Error("Sanity client not available");
+ await dashboardClient.patch(id).set({ status: "draft" }).commit();
+ revalidatePath("/dashboard/videos");
+}
+
+export async function retryRender(id: string) {
+ if (!dashboardClient) throw new Error("Sanity client not available");
+ await dashboardClient.patch(id).set({ status: "video_gen" }).commit();
+ revalidatePath("/dashboard/videos");
+}
+
+export async function publishAnyway(id: string) {
+ if (!dashboardClient) throw new Error("Sanity client not available");
+ await dashboardClient
+ .patch(id)
+ .set({ status: "published" })
+ .unset(["flaggedReason"])
+ .commit();
+ revalidatePath("/dashboard/videos");
+}
diff --git a/apps/web/app/(dashboard)/dashboard/videos/page.tsx b/apps/web/app/(dashboard)/dashboard/videos/page.tsx
new file mode 100644
index 000000000..439fa30f1
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/videos/page.tsx
@@ -0,0 +1,75 @@
+import { Suspense } from "react";
+import { connection } from "next/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import { VideosTable } from "./videos-table";
+import { PageRefreshButton } from "@/components/page-refresh-button";
+
+interface AutomatedVideo {
+ _id: string;
+ _createdAt: string;
+ title: string;
+ status:
+ | "draft"
+ | "script_ready"
+ | "audio_gen"
+ | "video_gen"
+ | "flagged"
+ | "uploading"
+ | "published";
+ flaggedReason?: string;
+ scriptQualityScore?: number;
+ scheduledPublishAt?: string;
+ youtubeId?: string;
+}
+
+const VIDEOS_QUERY = `*[_type == "automatedVideo"] | order(_createdAt desc) {
+ _id,
+ _createdAt,
+ title,
+ status,
+ flaggedReason,
+ scriptQualityScore,
+ scheduledPublishAt,
+ youtubeId
+}`;
+
+export default function VideosPage() {
+ return (
+
+
+
+
+ Automated Videos
+
+
+ Monitor the video pipeline — from script generation to
+ publishing.
+
+
+
+
+
+
Loading videos...
+ }
+ >
+
+
+
+ );
+}
+
+async function VideosContent() {
+ await connection();
+
+ let videos: AutomatedVideo[] = [];
+
+ try {
+ videos = await dashboardQuery(VIDEOS_QUERY);
+ } catch (error) {
+ console.error("Failed to fetch videos:", error);
+ }
+
+ return ;
+}
diff --git a/apps/web/app/(dashboard)/dashboard/videos/videos-table.tsx b/apps/web/app/(dashboard)/dashboard/videos/videos-table.tsx
new file mode 100644
index 000000000..f945196e0
--- /dev/null
+++ b/apps/web/app/(dashboard)/dashboard/videos/videos-table.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { AlertTriangle, FileVideo } from "lucide-react";
+import { regenerateScript, retryRender, publishAnyway } from "./actions";
+
+interface AutomatedVideo {
+ _id: string;
+ _createdAt: string;
+ title: string;
+ status:
+ | "draft"
+ | "script_ready"
+ | "audio_gen"
+ | "video_gen"
+ | "flagged"
+ | "uploading"
+ | "published";
+ flaggedReason?: string;
+ scriptQualityScore?: number;
+ scheduledPublishAt?: string;
+ youtubeId?: string;
+}
+
+const statusConfig: Record = {
+ draft: {
+ label: "Draft",
+ className:
+ "bg-gray-100 text-gray-800 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300",
+ },
+ script_ready: {
+ label: "Script Ready",
+ className:
+ "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300",
+ },
+ audio_gen: {
+ label: "Audio Gen",
+ className:
+ "bg-orange-100 text-orange-800 hover:bg-orange-100 dark:bg-orange-900 dark:text-orange-300",
+ },
+ video_gen: {
+ label: "Video Gen",
+ className:
+ "bg-blue-100 text-blue-800 hover:bg-blue-100 dark:bg-blue-900 dark:text-blue-300",
+ },
+ flagged: {
+ label: "Flagged",
+ className:
+ "bg-red-100 text-red-800 hover:bg-red-100 dark:bg-red-900 dark:text-red-300",
+ },
+ uploading: {
+ label: "Uploading",
+ className:
+ "bg-purple-100 text-purple-800 hover:bg-purple-100 dark:bg-purple-900 dark:text-purple-300",
+ },
+ published: {
+ label: "Published",
+ className:
+ "bg-green-100 text-green-800 hover:bg-green-100 dark:bg-green-900 dark:text-green-300",
+ },
+};
+
+function StatusBadge({ status }: { status: string }) {
+ const config = statusConfig[status] ?? { label: status, className: "" };
+ return (
+
+ {config.label}
+
+ );
+}
+
+function formatDate(dateString: string) {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+export function VideosTable({ videos }: { videos: AutomatedVideo[] }) {
+ if (videos.length === 0) {
+ return (
+
+
+
No videos yet
+
+ Automated videos will appear here once the pipeline starts
+ processing content ideas.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Title
+ Status
+ Flagged
+ Score
+ Created
+ Actions
+
+
+
+ {videos.map((video) => (
+
+
+ {video.title || "Untitled"}
+
+
+
+
+
+ {video.status === "flagged" ? (
+
+
+
+
+
+
+
+ {video.flaggedReason || "Flagged for review"}
+
+
+ ) : (
+ \u2014
+ )}
+
+
+ {video.scriptQualityScore ?? "\u2014"}
+
+
+ {formatDate(video._createdAt)}
+
+
+
+ {(video.status === "flagged" || video.status === "draft") && (
+
+ )}
+ {video.status === "flagged" && (
+
+ )}
+ {video.status === "flagged" && (
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
new file mode 100644
index 000000000..ebcddb651
--- /dev/null
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -0,0 +1,103 @@
+import "../globals.css";
+
+import type { Metadata } from "next";
+import { Nunito, Inter } from "next/font/google";
+import { cn } from "@/lib/utils";
+import { ThemeProvider } from "@/components/theme-provider";
+import { AppSidebar } from "@/components/app-sidebar";
+import { SiteHeader } from "@/components/site-header";
+import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
+import { Toaster } from "@/components/ui/sonner";
+import { SiteAnalytics } from "@/components/analytics";
+import { Suspense } from "react";
+
+const nunito = Nunito({
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-nunito",
+});
+const inter = Inter({
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-inter",
+});
+
+export const metadata: Metadata = {
+ title: {
+ template: "%s | CodingCat.dev Dashboard",
+ default: "Content Ops Dashboard | CodingCat.dev",
+ },
+ description: "Manage your automated content engine",
+};
+
+export default function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {/* Reading the Supabase session uses cookies (a dynamic API), so it
+ must live inside a Suspense boundary under cacheComponents. The
+ fallback renders children without the authenticated chrome. */}
+ {children}>}>
+ {children}
+
+
+
+
+
+
+ );
+}
+
+async function DashboardChrome({ children }: { children: React.ReactNode }) {
+ // Try to get user — if Supabase isn't configured or user isn't logged in,
+ // the proxy will have already redirected to login for protected routes.
+ // The login page itself renders without the sidebar chrome.
+ let user = null;
+ try {
+ const supabaseUrl =
+ process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
+ const supabaseAnonKey =
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
+
+ if (supabaseUrl && supabaseAnonKey) {
+ const { createClient } = await import("@/lib/supabase/server");
+ const supabase = await createClient();
+ const { data } = await supabase.auth.getUser();
+ user = data?.user ?? null;
+ }
+ } catch {
+ // Supabase not available — continue without user
+ }
+
+ if (!user) {
+ return <>{children}>;
+ }
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(author)/author/[slug]/page.tsx b/apps/web/app/(main)/(author)/author/[slug]/page.tsx
new file mode 100644
index 000000000..4b1772890
--- /dev/null
+++ b/apps/web/app/(main)/(author)/author/[slug]/page.tsx
@@ -0,0 +1,161 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import { type PortableTextBlock } from "next-sanity";
+import { notFound } from "next/navigation";
+
+import PortableText from "@/components/portable-text";
+
+import type {
+ AuthorQueryResult,
+ AuthorQueryWithRelatedResult,
+} from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import {
+ authorQuery,
+ authorQueryWithRelated,
+ authorSlugsQuery,
+} from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import CoverMedia from "@/components/cover-media";
+import { BreadcrumbLinks } from "@/components/breadrumb-links";
+
+import UserSocials from "@/components/user-socials";
+import UserRelated from "@/components/user-related";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+import { JsonLd } from "@/components/json-ld";
+import { breadcrumbSchema, buildGraph, personSchema } from "@/lib/structured-data";
+
+type Params = Promise<{ slug: string }>;
+
+export async function generateStaticParams() {
+ const { data } = await sanityFetchStaticParams({ query: authorSlugsQuery });
+ return data as { slug: string }[];
+}
+
+export async function generateMetadata(
+ { params }: { params: Params },
+ parent: ResolvingMetadata,
+): Promise {
+ const [{ slug }, { perspective }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+
+ const author = (
+ await sanityFetchMetadata({
+ query: authorQuery,
+ params: { slug },
+ perspective,
+ })
+ ).data as AuthorQueryResult;
+
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(author?.coverImage);
+
+ return {
+ title: author?.title,
+ description: author?.excerpt,
+ alternates: { canonical: `/author/${slug}` },
+ openGraph: {
+ type: "profile",
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function AuthorPage({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { slug } = await params;
+ return ;
+}
+
+async function DynamicAuthorPage({ params }: { params: Params }) {
+ const [{ slug }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return (
+
+ );
+}
+
+async function CachedAuthorPage({
+ slug,
+ perspective,
+ stega,
+}: { slug: string } & DynamicFetchOptions) {
+ "use cache";
+ const author = (
+ await sanityFetch({
+ query: authorQueryWithRelated,
+ params: { slug },
+ perspective,
+ stega,
+ })
+ ).data as AuthorQueryWithRelatedResult;
+
+ if (!author?._id) {
+ return notFound();
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {author.title}
+
+ {author?.socials && (
+
+
+
+ )}
+
+
+
+ {author.content?.length && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(author)/authors/page.tsx b/apps/web/app/(main)/(author)/authors/page.tsx
new file mode 100644
index 000000000..37326b6fb
--- /dev/null
+++ b/apps/web/app/(main)/(author)/authors/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/authors/page/1");
+}
diff --git a/apps/web/app/(main)/(author)/authors/page/[num]/page.tsx b/apps/web/app/(main)/(author)/authors/page/[num]/page.tsx
new file mode 100644
index 000000000..4e68ea2eb
--- /dev/null
+++ b/apps/web/app/(main)/(author)/authors/page/[num]/page.tsx
@@ -0,0 +1,86 @@
+import MoreContent from "@/components/more-content";
+import type { DocCountResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+
+import PaginateList from "@/components/paginate-list";
+import { docCount } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+const LIMIT = 10;
+
+type Params = Promise<{ num: string }>;
+
+export async function generateStaticParams() {
+ const { data: count } = await sanityFetchStaticParams({
+ query: docCount,
+ params: { type: "author" },
+ });
+ const pages = Math.max(1, Math.ceil(((count as number) ?? 0) / LIMIT));
+ return Array.from({ length: pages }, (_, i) => ({ num: String(i + 1) }));
+}
+
+export default async function Page({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { num } = await params;
+ return ;
+}
+
+async function DynamicPage({ params }: { params: Params }) {
+ const [{ num }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPage({
+ num,
+ perspective,
+ stega,
+}: { num: string } & DynamicFetchOptions) {
+ "use cache";
+ const count = (
+ await sanityFetch({
+ query: docCount,
+ params: { type: "author" },
+ perspective,
+ stega,
+ })
+ ).data as DocCountResult;
+
+ const pageNumber = Number(num);
+ const offset = (pageNumber - 1) * LIMIT;
+ const limit = offset + LIMIT;
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(main)/(author)/authors/page/page.tsx b/apps/web/app/(main)/(author)/authors/page/page.tsx
new file mode 100644
index 000000000..37326b6fb
--- /dev/null
+++ b/apps/web/app/(main)/(author)/authors/page/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/authors/page/1");
+}
diff --git a/apps/web/app/(main)/(guest)/guest/[slug]/page.tsx b/apps/web/app/(main)/(guest)/guest/[slug]/page.tsx
new file mode 100644
index 000000000..35c8b357d
--- /dev/null
+++ b/apps/web/app/(main)/(guest)/guest/[slug]/page.tsx
@@ -0,0 +1,159 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import { type PortableTextBlock } from "next-sanity";
+import { notFound } from "next/navigation";
+
+import PortableText from "@/components/portable-text";
+
+import type {
+ GuestQueryResult,
+ GuestQueryWithRelatedResult,
+} from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import {
+ guestQuery,
+ guestQueryWithRelated,
+ guestSlugsQuery,
+} from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import CoverMedia from "@/components/cover-media";
+import { BreadcrumbLinks } from "@/components/breadrumb-links";
+
+import UserSocials from "@/components/user-socials";
+import UserRelated from "@/components/user-related";
+import Avatar from "@/components/avatar";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+import { JsonLd } from "@/components/json-ld";
+import { breadcrumbSchema, buildGraph, personSchema } from "@/lib/structured-data";
+
+type Params = Promise<{ slug: string }>;
+
+export async function generateStaticParams() {
+ const { data } = await sanityFetchStaticParams({ query: guestSlugsQuery });
+ return data as { slug: string }[];
+}
+
+export async function generateMetadata(
+ { params }: { params: Params },
+ parent: ResolvingMetadata,
+): Promise {
+ const [{ slug }, { perspective }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+
+ const guest = (
+ await sanityFetchMetadata({
+ query: guestQuery,
+ params: { slug },
+ perspective,
+ })
+ ).data as GuestQueryResult;
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(guest?.coverImage);
+
+ return {
+ title: guest?.title,
+ description: guest?.excerpt,
+ alternates: { canonical: `/guest/${slug}` },
+ openGraph: {
+ type: "profile",
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function GuestPage({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { slug } = await params;
+ return ;
+}
+
+async function DynamicGuestPage({ params }: { params: Params }) {
+ const [{ slug }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedGuestPage({
+ slug,
+ perspective,
+ stega,
+}: { slug: string } & DynamicFetchOptions) {
+ "use cache";
+ const guest = (
+ await sanityFetch({
+ query: guestQueryWithRelated,
+ params: { slug },
+ perspective,
+ stega,
+ })
+ ).data as GuestQueryWithRelatedResult;
+
+ if (!guest?._id) {
+ return notFound();
+ }
+
+ return (
+
+
+
+
+
+ {guest?.coverImage && (
+
+ )}
+
+
+ {guest.title}
+
+ {guest?.socials && (
+
+
+
+ )}
+
+
+
+ {guest.content?.length && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(guest)/guests/page.tsx b/apps/web/app/(main)/(guest)/guests/page.tsx
new file mode 100644
index 000000000..c7cf17dbc
--- /dev/null
+++ b/apps/web/app/(main)/(guest)/guests/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/guests/page/1");
+}
diff --git a/apps/web/app/(main)/(guest)/guests/page/[num]/page.tsx b/apps/web/app/(main)/(guest)/guests/page/[num]/page.tsx
new file mode 100644
index 000000000..cc0623474
--- /dev/null
+++ b/apps/web/app/(main)/(guest)/guests/page/[num]/page.tsx
@@ -0,0 +1,81 @@
+import MoreContent from "@/components/more-content";
+import type { DocCountResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+
+import PaginateList from "@/components/paginate-list";
+import { docCount } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+const LIMIT = 10;
+
+type Params = Promise<{ num: string }>;
+
+export async function generateStaticParams() {
+ const { data: count } = await sanityFetchStaticParams({
+ query: docCount,
+ params: { type: "guest" },
+ });
+ const pages = Math.max(1, Math.ceil(((count as number) ?? 0) / LIMIT));
+ return Array.from({ length: pages }, (_, i) => ({ num: String(i + 1) }));
+}
+
+export default async function Page({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { num } = await params;
+ return ;
+}
+
+async function DynamicPage({ params }: { params: Params }) {
+ const [{ num }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPage({
+ num,
+ perspective,
+ stega,
+}: { num: string } & DynamicFetchOptions) {
+ "use cache";
+ const count = (
+ await sanityFetch({
+ query: docCount,
+ params: { type: "guest" },
+ perspective,
+ stega,
+ })
+ ).data as DocCountResult;
+
+ const pageNumber = Number(num);
+ const offset = (pageNumber - 1) * LIMIT;
+ const limit = offset + LIMIT;
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(main)/(guest)/guests/page/page.tsx b/apps/web/app/(main)/(guest)/guests/page/page.tsx
new file mode 100644
index 000000000..c7cf17dbc
--- /dev/null
+++ b/apps/web/app/(main)/(guest)/guests/page/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/guests/page/1");
+}
diff --git a/apps/web/app/(main)/(podcast)/podcast/Podcast.tsx b/apps/web/app/(main)/(podcast)/podcast/Podcast.tsx
new file mode 100644
index 000000000..277d1bf10
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcast/Podcast.tsx
@@ -0,0 +1,151 @@
+import type { PortableTextBlock } from "next-sanity";
+import { Suspense } from "react";
+
+import DateComponent from "@/components/date";
+import MoreContent from "@/components/more-content";
+import PortableText from "@/components/portable-text";
+
+import type { PodcastQueryResult } from "@/sanity/types";
+import type { DynamicFetchOptions } from "@/sanity/lib/live";
+import CoverMedia from "@/components/cover-media";
+import MoreHeader from "@/components/more-header";
+import { BreadcrumbLinks } from "@/components/breadrumb-links";
+import SponsorCard from "@/components/sponsor-card";
+import Avatar from "@/components/avatar";
+import Picks from "./picks";
+import PlayerPlayButton from "@/components/player-play-button";
+import PodcastOpenSpotify from "@/components/podcast-open-spotify";
+import PodcastOpenApple from "@/components/podcast-open-apple";
+import PodcastOpenYouTube from "@/components/podcast-open-youtube";
+
+import PodmatchBadge from "@/components/podmatch-badge";
+
+export default async function Podcast({
+ podcast,
+ perspective,
+ stega,
+}: {
+ podcast: NonNullable;
+} & DynamicFetchOptions) {
+ const src = podcast?.spotify?.enclosures?.at(0)?.url;
+
+ return (
+
+
+
+
+ {podcast.title}
+
+
+
+
+
+
+
+
+
+
+ {(podcast?.author || podcast?.guest) && (
+
+ {podcast?.author?.map((a, idx) => (
+
+ ))}
+ {podcast?.guest?.map((a, idx) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ {src && (
+
+
+ Listening Options
+
+
+
or
+
+
+ )}
+
+ {podcast?.sponsor?.length && (
+
+ )}
+
+ {podcast?.content?.length && (
+
+ )}
+
+
+
+ {podcast?.pick?.length && (
+ <>
+
+
+
+ Picks
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(podcast)/podcast/[slug]/page.tsx b/apps/web/app/(main)/(podcast)/podcast/[slug]/page.tsx
new file mode 100644
index 000000000..3a8d93917
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcast/[slug]/page.tsx
@@ -0,0 +1,119 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import { notFound } from "next/navigation";
+import type { PodcastQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { podcastQuery, podcastSlugsQuery } from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import Podcast from "../Podcast";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+import { JsonLd } from "@/components/json-ld";
+import { articleSchema, breadcrumbSchema, buildGraph } from "@/lib/structured-data";
+
+type Params = Promise<{ slug: string }>;
+
+export async function generateStaticParams() {
+ const { data } = await sanityFetchStaticParams({ query: podcastSlugsQuery });
+ return data as { slug: string }[];
+}
+
+export async function generateMetadata(
+ { params }: { params: Params },
+ parent: ResolvingMetadata,
+): Promise {
+ const [{ slug }, { perspective }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+
+ const podcast = (
+ await sanityFetchMetadata({
+ query: podcastQuery,
+ params: { slug },
+ perspective,
+ })
+ ).data as PodcastQueryResult;
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(podcast?.coverImage);
+
+ return {
+ authors:
+ podcast?.author?.map((a) => {
+ return { name: a.title };
+ }) || [],
+ title: podcast?.title,
+ description: podcast?.excerpt,
+ alternates: { canonical: `/podcast/${slug}` },
+ openGraph: {
+ type: "article",
+ ...(podcast?.date ? { publishedTime: podcast.date } : {}),
+ modifiedTime: podcast?._updatedAt,
+ authors: podcast?.author?.map((a) => a.title) ?? [],
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function PodcastPage({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { slug } = await params;
+ return ;
+}
+
+async function DynamicPodcastPage({ params }: { params: Params }) {
+ const [{ slug }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return (
+
+ );
+}
+
+async function CachedPodcastPage({
+ slug,
+ perspective,
+ stega,
+}: { slug: string } & DynamicFetchOptions) {
+ "use cache";
+ const podcast = (
+ await sanityFetch({
+ query: podcastQuery,
+ params: { slug },
+ perspective,
+ stega,
+ })
+ ).data as PodcastQueryResult;
+
+ if (!podcast?._id) {
+ return notFound();
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/web/app/(main)/(podcast)/podcast/picks.tsx b/apps/web/app/(main)/(podcast)/podcast/picks.tsx
new file mode 100644
index 000000000..ea1805af8
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcast/picks.tsx
@@ -0,0 +1,88 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import type { PodcastQueryResult } from "@/sanity/types";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import Link from "next/link";
+import { FaExternalLinkSquareAlt } from "react-icons/fa";
+
+export default async function PodcastPage({
+ picks,
+}: {
+ picks: NonNullable["pick"]>;
+}) {
+ const groupedPicks = picks.reduce(
+ (acc, pick) => {
+ const author = pick?.user?.title;
+ if (!author) {
+ return acc;
+ }
+ if (!acc?.[author]) {
+ acc[author] = [];
+ }
+ acc[author].push(pick);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ const sortedPicks = Object.entries(groupedPicks).sort(([userA], [userB]) =>
+ userA.localeCompare(userB),
+ );
+
+ return (
+ <>
+ {sortedPicks.map(([author, picksByAuthor]) => (
+
+
+
+
+ {author}
+
+
+
+
+
+
+
+
+ Picks
+
+
+
+
+ {picksByAuthor.map((pick) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/apps/web/app/(main)/(podcast)/podcast/preview/[token]/page.tsx b/apps/web/app/(main)/(podcast)/podcast/preview/[token]/page.tsx
new file mode 100644
index 000000000..302ef2894
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcast/preview/[token]/page.tsx
@@ -0,0 +1,49 @@
+import { notFound } from "next/navigation";
+import { headers } from "next/headers";
+import { Suspense } from "react";
+import Podcast from "../../Podcast";
+import { getDynamicFetchOptions } from "@/sanity/lib/live";
+
+export default function PreviewPage({
+ params,
+}: {
+ params: Promise<{ token: string }>;
+}) {
+ return (
+ }>
+
+
+ );
+}
+
+async function PreviewContent({
+ params,
+}: {
+ params: Promise<{ token: string }>;
+}) {
+ const { token } = await params;
+ if (!token) return notFound();
+
+ // Build absolute URL for API call
+ const headersList = await headers();
+ const host = headersList.get("host");
+ const protocol = host?.startsWith("localhost") ? "http" : "https";
+ const apiUrl = `${protocol}://${host}/api/get-preview-content`;
+
+ const res = await fetch(apiUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token }),
+ cache: "no-store",
+ });
+
+ if (!res.ok) return notFound();
+ const data = await res.json();
+
+ if (!data || !data.document) return notFound();
+
+ const { perspective, stega } = await getDynamicFetchOptions();
+ return (
+
+ );
+}
diff --git a/apps/web/app/(main)/(podcast)/podcasts/page.tsx b/apps/web/app/(main)/(podcast)/podcasts/page.tsx
new file mode 100644
index 000000000..8c05f5885
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcasts/page.tsx
@@ -0,0 +1,141 @@
+import Link from "next/link";
+import { Suspense } from "react";
+
+import Avatar from "@/components/avatar";
+import CoverImage from "@/components/cover-image";
+import DateComponent from "@/components/date";
+import MoreContent from "@/components/more-content";
+import Onboarding from "@/components/onboarding";
+
+import type { PodcastsQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { podcastsQuery } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import MoreHeader from "@/components/more-header";
+
+import PodmatchBadge from "@/components/podmatch-badge";
+
+function HeroPodcast({
+ title,
+ slug,
+ excerpt,
+ coverImage,
+ date,
+ author,
+ guest,
+}: Pick<
+ Exclude,
+ "title" | "coverImage" | "date" | "excerpt" | "author" | "slug" | "guest"
+>) {
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+ {(author || guest) && (
+
+ {author?.map((a) => (
+
+ ))}
+ {guest?.map((a) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export default async function Page() {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ return ;
+}
+
+async function DynamicPage() {
+ const { perspective, stega } = await getDynamicFetchOptions();
+ return ;
+}
+
+async function CachedPage({ perspective, stega }: DynamicFetchOptions) {
+ "use cache";
+ const heroPost = (
+ await sanityFetch({ query: podcastsQuery, perspective, stega })
+ ).data as PodcastsQueryResult;
+ return (
+
+ {heroPost ? (
+
+ ) : (
+
+ )}
+
+
+ {heroPost?._id && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/(main)/(podcast)/podcasts/page/[num]/page.tsx b/apps/web/app/(main)/(podcast)/podcasts/page/[num]/page.tsx
new file mode 100644
index 000000000..319ed72c3
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcasts/page/[num]/page.tsx
@@ -0,0 +1,86 @@
+import MoreContent from "@/components/more-content";
+import type { DocCountResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+
+import PaginateList from "@/components/paginate-list";
+import { docCount } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+const LIMIT = 10;
+
+type Params = Promise<{ num: string }>;
+
+export async function generateStaticParams() {
+ const { data: count } = await sanityFetchStaticParams({
+ query: docCount,
+ params: { type: "podcast" },
+ });
+ const pages = Math.max(1, Math.ceil(((count as number) ?? 0) / LIMIT));
+ return Array.from({ length: pages }, (_, i) => ({ num: String(i + 1) }));
+}
+
+export default async function Page({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { num } = await params;
+ return ;
+}
+
+async function DynamicPage({ params }: { params: Params }) {
+ const [{ num }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPage({
+ num,
+ perspective,
+ stega,
+}: { num: string } & DynamicFetchOptions) {
+ "use cache";
+ const count = (
+ await sanityFetch({
+ query: docCount,
+ params: { type: "podcast" },
+ perspective,
+ stega,
+ })
+ ).data as DocCountResult;
+
+ const pageNumber = Number(num);
+ const offset = (pageNumber - 1) * LIMIT;
+ const limit = offset + LIMIT;
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(main)/(podcast)/podcasts/page/page.tsx b/apps/web/app/(main)/(podcast)/podcasts/page/page.tsx
new file mode 100644
index 000000000..915dc0849
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcasts/page/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/podcasts/page/1");
+}
diff --git a/apps/web/app/(main)/(podcast)/podcasts/rss.json/route.ts b/apps/web/app/(main)/(podcast)/podcasts/rss.json/route.ts
new file mode 100644
index 000000000..c2d556a02
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcasts/rss.json/route.ts
@@ -0,0 +1,14 @@
+import { getFeedJson } from "@/lib/rss";
+import { ContentType } from "@/lib/types";
+import { getDynamicFetchOptions } from "@/sanity/lib/live";
+
+export async function GET() {
+ const { perspective } = await getDynamicFetchOptions();
+ const json = await getFeedJson({ type: ContentType.podcast, perspective });
+ return new Response(json, {
+ headers: {
+ "content-type": "application/json",
+ "cache-control": "max-age=0, s-maxage=3600",
+ },
+ });
+}
diff --git a/apps/web/app/(main)/(podcast)/podcasts/rss.xml/route.ts b/apps/web/app/(main)/(podcast)/podcasts/rss.xml/route.ts
new file mode 100644
index 000000000..4ac89f213
--- /dev/null
+++ b/apps/web/app/(main)/(podcast)/podcasts/rss.xml/route.ts
@@ -0,0 +1,13 @@
+import { getPodcastFeedXml } from "@/lib/rss";
+import { getDynamicFetchOptions } from "@/sanity/lib/live";
+
+export async function GET() {
+ const { perspective } = await getDynamicFetchOptions();
+ const xml = await getPodcastFeedXml({ perspective });
+ return new Response(xml, {
+ headers: {
+ "content-type": "application/rss+xml; charset=utf-8",
+ "cache-control": "max-age=0, s-maxage=3600",
+ },
+ });
+}
diff --git a/apps/web/app/(main)/(post)/blog/page.tsx b/apps/web/app/(main)/(post)/blog/page.tsx
new file mode 100644
index 000000000..31105d92b
--- /dev/null
+++ b/apps/web/app/(main)/(post)/blog/page.tsx
@@ -0,0 +1,127 @@
+import Link from "next/link";
+import { Suspense } from "react";
+
+import Avatar from "@/components/avatar";
+import CoverImage from "@/components/cover-image";
+import DateComponent from "@/components/date";
+import MoreContent from "@/components/more-content";
+import Onboarding from "@/components/onboarding";
+
+import type { BlogQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { blogQuery } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+
+import MoreHeader from "@/components/more-header";
+
+function HeroPost({
+ title,
+ slug,
+ excerpt,
+ coverImage,
+ date,
+ author,
+}: Pick<
+ Exclude,
+ "title" | "coverImage" | "date" | "excerpt" | "author" | "slug"
+>) {
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+ {author && (
+
+ {author.map((a) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export default async function Page() {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ return ;
+}
+
+async function DynamicPage() {
+ const { perspective, stega } = await getDynamicFetchOptions();
+ return ;
+}
+
+async function CachedPage({ perspective, stega }: DynamicFetchOptions) {
+ "use cache";
+ const heroPost = (
+ await sanityFetch({ query: blogQuery, perspective, stega })
+ ).data as BlogQueryResult;
+ return (
+
+ {heroPost ? (
+
+ ) : (
+
+ )}
+
+ {heroPost?._id && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/(main)/(post)/blog/page/[num]/page.tsx b/apps/web/app/(main)/(post)/blog/page/[num]/page.tsx
new file mode 100644
index 000000000..44fa7fcf8
--- /dev/null
+++ b/apps/web/app/(main)/(post)/blog/page/[num]/page.tsx
@@ -0,0 +1,81 @@
+import MoreContent from "@/components/more-content";
+import type { DocCountResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+
+import PaginateList from "@/components/paginate-list";
+import { docCount } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+const LIMIT = 10;
+
+type Params = Promise<{ num: string }>;
+
+export async function generateStaticParams() {
+ const { data: count } = await sanityFetchStaticParams({
+ query: docCount,
+ params: { type: "post" },
+ });
+ const pages = Math.max(1, Math.ceil(((count as number) ?? 0) / LIMIT));
+ return Array.from({ length: pages }, (_, i) => ({ num: String(i + 1) }));
+}
+
+export default async function Page({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { num } = await params;
+ return ;
+}
+
+async function DynamicPage({ params }: { params: Params }) {
+ const [{ num }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPage({
+ num,
+ perspective,
+ stega,
+}: { num: string } & DynamicFetchOptions) {
+ "use cache";
+ const count = (
+ await sanityFetch({
+ query: docCount,
+ params: { type: "post" },
+ perspective,
+ stega,
+ })
+ ).data as DocCountResult;
+
+ const pageNumber = Number(num);
+ const offset = (pageNumber - 1) * LIMIT;
+ const limit = offset + LIMIT;
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(main)/(post)/blog/page/page.tsx b/apps/web/app/(main)/(post)/blog/page/page.tsx
new file mode 100644
index 000000000..f5d45011a
--- /dev/null
+++ b/apps/web/app/(main)/(post)/blog/page/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/blog/page/1");
+}
diff --git a/apps/web/app/(main)/(post)/blog/rss.json/route.ts b/apps/web/app/(main)/(post)/blog/rss.json/route.ts
new file mode 100644
index 000000000..1710a0e7c
--- /dev/null
+++ b/apps/web/app/(main)/(post)/blog/rss.json/route.ts
@@ -0,0 +1,14 @@
+import { getFeedJson } from "@/lib/rss";
+import { ContentType } from "@/lib/types";
+import { getDynamicFetchOptions } from "@/sanity/lib/live";
+
+export async function GET() {
+ const { perspective } = await getDynamicFetchOptions();
+ const json = await getFeedJson({ type: ContentType.post, perspective });
+ return new Response(json, {
+ headers: {
+ "content-type": "application/json",
+ "cache-control": "max-age=0, s-maxage=3600",
+ },
+ });
+}
diff --git a/apps/web/app/(main)/(post)/blog/rss.xml/route.ts b/apps/web/app/(main)/(post)/blog/rss.xml/route.ts
new file mode 100644
index 000000000..13cb4d6f6
--- /dev/null
+++ b/apps/web/app/(main)/(post)/blog/rss.xml/route.ts
@@ -0,0 +1,14 @@
+import { getFeedRss2 } from "@/lib/rss";
+import { ContentType } from "@/lib/types";
+import { getDynamicFetchOptions } from "@/sanity/lib/live";
+
+export async function GET() {
+ const { perspective } = await getDynamicFetchOptions();
+ const rss = await getFeedRss2({ type: ContentType.post, perspective });
+ return new Response(rss, {
+ headers: {
+ "content-type": "application/rss+xml; charset=utf-8",
+ "cache-control": "max-age=0, s-maxage=3600",
+ },
+ });
+}
diff --git a/apps/web/app/(main)/(post)/post/[slug]/page.tsx b/apps/web/app/(main)/(post)/post/[slug]/page.tsx
new file mode 100644
index 000000000..03927e5a7
--- /dev/null
+++ b/apps/web/app/(main)/(post)/post/[slug]/page.tsx
@@ -0,0 +1,183 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import { type PortableTextBlock } from "next-sanity";
+import { notFound } from "next/navigation";
+import { Suspense } from "react";
+
+import Avatar from "@/components/avatar";
+import DateComponent from "@/components/date";
+import MoreContent from "@/components/more-content";
+import PortableText from "@/components/portable-text";
+
+import type { PostQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { postQuery, postSlugsQuery } from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import CoverMedia from "@/components/cover-media";
+import MoreHeader from "@/components/more-header";
+import { BreadcrumbLinks } from "@/components/breadrumb-links";
+import SponsorCard from "@/components/sponsor-card";
+import { draftMode } from "next/headers";
+import { JsonLd } from "@/components/json-ld";
+import { articleSchema, breadcrumbSchema, buildGraph } from "@/lib/structured-data";
+
+type Params = Promise<{ slug: string }>;
+
+export async function generateStaticParams() {
+ const { data } = await sanityFetchStaticParams({ query: postSlugsQuery });
+ return data as { slug: string }[];
+}
+
+export async function generateMetadata(
+ { params }: { params: Params },
+ parent: ResolvingMetadata,
+): Promise {
+ const [{ slug }, { perspective }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+
+ const post = (
+ await sanityFetchMetadata({
+ query: postQuery,
+ params: { slug },
+ perspective,
+ })
+ ).data as PostQueryResult;
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(post?.coverImage);
+
+ return {
+ authors:
+ post?.author?.map((a) => {
+ return { name: a.title };
+ }) || [],
+ title: post?.title,
+ description: post?.excerpt,
+ alternates: { canonical: `/post/${slug}` },
+ openGraph: {
+ type: "article",
+ ...(post?.date ? { publishedTime: post.date } : {}),
+ modifiedTime: post?._updatedAt,
+ authors: post?.author?.map((a) => a.title) ?? [],
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function PostPage({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { slug } = await params;
+ return ;
+}
+
+async function DynamicPostPage({ params }: { params: Params }) {
+ const [{ slug }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPostPage({
+ slug,
+ perspective,
+ stega,
+}: { slug: string } & DynamicFetchOptions) {
+ "use cache";
+ const post = (
+ await sanityFetch({ query: postQuery, params: { slug }, perspective, stega })
+ ).data as PostQueryResult;
+
+ if (!post?._id) {
+ return notFound();
+ }
+
+ return (
+
+
+
+
+
+ {post.title}
+
+
+
+
+
+
+
+ {post?.author && (
+
+ {post?.author?.map((a) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {post?.sponsor?.length && (
+
+ )}
+ {post.content?.length && (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(sponsor)/sponsor/[slug]/page.tsx b/apps/web/app/(main)/(sponsor)/sponsor/[slug]/page.tsx
new file mode 100644
index 000000000..5c7ba32b0
--- /dev/null
+++ b/apps/web/app/(main)/(sponsor)/sponsor/[slug]/page.tsx
@@ -0,0 +1,145 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import { type PortableTextBlock } from "next-sanity";
+import { notFound } from "next/navigation";
+
+import PortableText from "@/components/portable-text";
+
+import type {
+ SponsorQueryResult,
+ SponsorQueryWithRelatedResult,
+} from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import {
+ sponsorQuery,
+ sponsorQueryWithRelated,
+ sponsorSlugsQuery,
+} from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import CoverMedia from "@/components/cover-media";
+import { BreadcrumbLinks } from "@/components/breadrumb-links";
+
+import UserSocials from "@/components/user-socials";
+import UserRelated from "@/components/user-related";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+type Params = Promise<{ slug: string }>;
+
+export async function generateStaticParams() {
+ const { data } = await sanityFetchStaticParams({ query: sponsorSlugsQuery });
+ return data as { slug: string }[];
+}
+
+export async function generateMetadata(
+ { params }: { params: Params },
+ parent: ResolvingMetadata,
+): Promise {
+ const [{ slug }, { perspective }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+
+ const sponsor = (
+ await sanityFetchMetadata({
+ query: sponsorQuery,
+ params: { slug },
+ perspective,
+ })
+ ).data as SponsorQueryResult;
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(sponsor?.coverImage);
+
+ return {
+ title: sponsor?.title,
+ description: sponsor?.excerpt,
+ alternates: { canonical: `/sponsor/${slug}` },
+ openGraph: {
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function SponsorPage({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { slug } = await params;
+ return (
+
+ );
+}
+
+async function DynamicSponsorPage({ params }: { params: Params }) {
+ const [{ slug }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return (
+
+ );
+}
+
+async function CachedSponsorPage({
+ slug,
+ perspective,
+ stega,
+}: { slug: string } & DynamicFetchOptions) {
+ "use cache";
+ const sponsor = (
+ await sanityFetch({
+ query: sponsorQueryWithRelated,
+ params: { slug },
+ perspective,
+ stega,
+ })
+ ).data as SponsorQueryWithRelatedResult;
+
+ if (!sponsor?._id) {
+ return notFound();
+ }
+
+ return (
+
+
+
+
+
+
+ {sponsor.title}
+
+ {sponsor?.socials && (
+
+
+
+ )}
+
+
+ {sponsor.content?.length && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(sponsor)/sponsors/page.tsx b/apps/web/app/(main)/(sponsor)/sponsors/page.tsx
new file mode 100644
index 000000000..b5f374065
--- /dev/null
+++ b/apps/web/app/(main)/(sponsor)/sponsors/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/sponsors/page/1");
+}
diff --git a/apps/web/app/(main)/(sponsor)/sponsors/page/[num]/page.tsx b/apps/web/app/(main)/(sponsor)/sponsors/page/[num]/page.tsx
new file mode 100644
index 000000000..dfc9d5e8a
--- /dev/null
+++ b/apps/web/app/(main)/(sponsor)/sponsors/page/[num]/page.tsx
@@ -0,0 +1,99 @@
+import { BecomeSponsorPopup } from "@/components/become-sponsor-popup";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+import MoreContent from "@/components/more-content";
+import type { DocCountResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+
+import PaginateList from "@/components/paginate-list";
+import { docCount } from "@/sanity/lib/queries";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+const LIMIT = 10;
+
+type Params = Promise<{ num: string }>;
+
+export async function generateStaticParams() {
+ const { data: count } = await sanityFetchStaticParams({
+ query: docCount,
+ params: { type: "sponsor" },
+ });
+ const pages = Math.max(1, Math.ceil(((count as number) ?? 0) / LIMIT));
+ return Array.from({ length: pages }, (_, i) => ({ num: String(i + 1) }));
+}
+
+export default async function Page({ params }: { params: Params }) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { num } = await params;
+ return ;
+}
+
+async function DynamicPage({ params }: { params: Params }) {
+ const [{ num }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPage({
+ num,
+ perspective,
+ stega,
+}: { num: string } & DynamicFetchOptions) {
+ "use cache";
+ const count = (
+ await sanityFetch({
+ query: docCount,
+ params: { type: "sponsor" },
+ perspective,
+ stega,
+ })
+ ).data as DocCountResult;
+
+ const pageNumber = Number(num);
+ const offset = (pageNumber - 1) * LIMIT;
+ const limit = offset + LIMIT;
+
+ return (
+
+
+
Want to see your name here?
+
+ Become a sponsor and support our content.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(sponsor)/sponsors/page/page.tsx b/apps/web/app/(main)/(sponsor)/sponsors/page/page.tsx
new file mode 100644
index 000000000..b5f374065
--- /dev/null
+++ b/apps/web/app/(main)/(sponsor)/sponsors/page/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+ redirect("/sponsors/page/1");
+}
diff --git a/apps/web/app/(main)/(top-level-pages)/[slug]/page.tsx b/apps/web/app/(main)/(top-level-pages)/[slug]/page.tsx
new file mode 100644
index 000000000..4756515d0
--- /dev/null
+++ b/apps/web/app/(main)/(top-level-pages)/[slug]/page.tsx
@@ -0,0 +1,113 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import { type PortableTextBlock } from "next-sanity";
+import { notFound } from "next/navigation";
+
+import PortableText from "@/components/portable-text";
+
+import type { PageQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ sanityFetchStaticParams,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { pageQuery, pageSlugsQuery } from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+type Props = {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+};
+
+export async function generateStaticParams() {
+ const { data } = await sanityFetchStaticParams({ query: pageSlugsQuery });
+ return data as { slug: string }[];
+}
+
+export async function generateMetadata(
+ { params }: Props,
+ parent: ResolvingMetadata,
+): Promise {
+ const [{ slug }, { perspective }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+
+ const page = (
+ await sanityFetchMetadata({
+ query: pageQuery,
+ params: { slug },
+ perspective,
+ })
+ ).data as PageQueryResult;
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(page?.coverImage);
+
+ return {
+ title: page?.title,
+ description: page?.excerpt,
+ alternates: { canonical: `/${slug}` },
+ openGraph: {
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function PagePage({ params }: Props) {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ const { slug } = await params;
+ return ;
+}
+
+async function DynamicPagePage({ params }: Pick) {
+ const [{ slug }, { perspective, stega }] = await Promise.all([
+ params,
+ getDynamicFetchOptions(),
+ ]);
+ return ;
+}
+
+async function CachedPagePage({
+ slug,
+ perspective,
+ stega,
+}: { slug: string } & DynamicFetchOptions) {
+ "use cache";
+ const page = (
+ await sanityFetch({ query: pageQuery, params: { slug }, perspective, stega })
+ ).data as PageQueryResult;
+
+ if (!page?._id) {
+ return notFound();
+ }
+
+ return (
+
+
+
+
+ {page.title}
+
+
+
+ {page.content?.length && (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(top-level-pages)/pro/page.tsx b/apps/web/app/(main)/(top-level-pages)/pro/page.tsx
new file mode 100644
index 000000000..573c563d2
--- /dev/null
+++ b/apps/web/app/(main)/(top-level-pages)/pro/page.tsx
@@ -0,0 +1,100 @@
+import type { Metadata, ResolvingMetadata } from "next";
+import type { PortableTextBlock } from "next-sanity";
+import { notFound } from "next/navigation";
+
+import PortableText from "@/components/portable-text";
+
+import type { PageQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ sanityFetchMetadata,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { pageQuery } from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import ProBenefits from "@/components/pro-benefits";
+import { Suspense } from "react";
+import { draftMode } from "next/headers";
+
+type Props = {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+};
+
+export async function generateMetadata(
+ _props: Props,
+ parent: ResolvingMetadata,
+): Promise {
+ const { perspective } = await getDynamicFetchOptions();
+ const page = (
+ await sanityFetchMetadata({
+ query: pageQuery,
+ params: { slug: "pro" },
+ perspective,
+ })
+ ).data as PageQueryResult;
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(page?.coverImage);
+
+ return {
+ title: page?.title,
+ description: page?.excerpt,
+ openGraph: {
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function ProPage() {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ return ;
+}
+
+async function DynamicProPage() {
+ const { perspective, stega } = await getDynamicFetchOptions();
+ return ;
+}
+
+async function CachedProPage({ perspective, stega }: DynamicFetchOptions) {
+ "use cache";
+ const page = (
+ await sanityFetch({
+ query: pageQuery,
+ params: { slug: "pro" },
+ perspective,
+ stega,
+ })
+ ).data as PageQueryResult;
+
+ if (!page?._id) {
+ return notFound();
+ }
+
+ return (
+
+
+ {page.coverImage && (
+
+
+
+ )}
+
+
+ {page.content?.length && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(top-level-pages)/search/page.tsx b/apps/web/app/(main)/(top-level-pages)/search/page.tsx
new file mode 100644
index 000000000..a4deb37ed
--- /dev/null
+++ b/apps/web/app/(main)/(top-level-pages)/search/page.tsx
@@ -0,0 +1,10 @@
+import React, { Suspense } from "react";
+
+import AlgoliaSearch from "@/components/algolia-search";
+export default function Page() {
+ return (
+ Loading...}>
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(top-level-pages)/sponsorships/page.tsx b/apps/web/app/(main)/(top-level-pages)/sponsorships/page.tsx
new file mode 100644
index 000000000..46116d9b6
--- /dev/null
+++ b/apps/web/app/(main)/(top-level-pages)/sponsorships/page.tsx
@@ -0,0 +1,108 @@
+import type { Metadata, ResolvingMetadata } from "next";
+
+import type { PageQueryResult } from "@/sanity/types";
+import {
+ sanityFetchMetadata,
+ getDynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { pageQuery } from "@/sanity/lib/queries";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { SponsorshipForm } from "./sponsorship-form";
+
+const sponsorshipTiers = [
+ {
+ name: "Dedicated Video",
+ price: "$4,000 USD",
+ description:
+ "A full video dedicated to your product or service. Includes a permanent logo and link on our sponsors page.",
+ value: "dedicated-video",
+ },
+ {
+ name: "Integrated Mid-Roll Ad (60 seconds)",
+ price: "$1,800 USD",
+ description:
+ "A 60-second ad integrated into the middle of a video. Includes a permanent logo and link on our sponsors page.",
+ value: "mid-roll-ad",
+ },
+ {
+ name: "Quick Shout-Out (30 seconds)",
+ price: "$900 USD",
+ description:
+ "A 30-second shout-out at the beginning of a video. Includes a permanent logo and link on our sponsors page.",
+ value: "shout-out",
+ },
+ {
+ name: "Blog Post / Newsletter Sponsorship",
+ price: "$500 USD",
+ description:
+ "Sponsor a blog post or our weekly newsletter. Your logo and a link will be featured.",
+ value: "blog-newsletter",
+ },
+ {
+ name: "Video Series (Custom Pricing)",
+ price: "Contact for pricing",
+ description:
+ "Sponsor a whole series of videos. Contact us for custom pricing and packages.",
+ value: "video-series",
+ },
+];
+
+export async function generateMetadata(
+ { params }: { params: any },
+ parent: ResolvingMetadata,
+): Promise {
+ const { perspective } = await getDynamicFetchOptions();
+ const page = (
+ await sanityFetchMetadata({
+ query: pageQuery,
+ params: { slug: "sponsorships" },
+ perspective,
+ })
+ ).data as PageQueryResult;
+
+ const previousImages = (await parent).openGraph?.images || [];
+ const ogImage = resolveOpenGraphImage(page?.coverImage);
+
+ return {
+ title: page?.title,
+ description: page?.excerpt,
+ openGraph: {
+ images: ogImage ? ogImage : previousImages,
+ },
+ } satisfies Metadata;
+}
+
+export default async function SponsorshipsPage() {
+ return (
+
+
+
+
+ Sponsor CodingCat.dev
+
+
+ Reach a large audience of developers, students, and tech
+ enthusiasts.
+
+
+
+
+ {sponsorshipTiers.map((tier) => (
+
+
+ {tier.name}
+
+
+ {tier.price}
+ {tier.description}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/(top-level-pages)/sponsorships/sponsorship-form.tsx b/apps/web/app/(main)/(top-level-pages)/sponsorships/sponsorship-form.tsx
new file mode 100644
index 000000000..de16f883a
--- /dev/null
+++ b/apps/web/app/(main)/(top-level-pages)/sponsorships/sponsorship-form.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import { z } from "zod";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Textarea } from "@/components/ui/textarea";
+import { CloudflareTurnstileWidget } from "@/components/cloudflare-turnstile";
+import { formSchema } from "@/lib/sponsorship-schema";
+
+export function SponsorshipForm({
+ sponsorshipTiers,
+}: {
+ sponsorshipTiers: any[];
+}) {
+ const [isSuccess, setIsSuccess] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const router = useRouter();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ fullName: "",
+ email: "",
+ companyName: "",
+ sponsorshipTier: [],
+ message: "",
+ honeypot: "",
+ "cf-turnstile-response": "",
+ },
+ });
+
+ async function onSubmit(values: z.infer) {
+ setIsSubmitting(true);
+ // Honeypot check
+ if (values.honeypot) {
+ toast.error("Spam detected!");
+ setIsSubmitting(false);
+ return;
+ }
+
+ const response = await fetch("/api/sponsorship", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(values),
+ });
+
+ const result = await response.json();
+
+ if (response.ok) {
+ toast.success("Sponsorship request submitted successfully!");
+ form.reset();
+ setIsSuccess(true);
+ setTimeout(() => {
+ router.push("/sponsors/page/1");
+ }, 3000);
+ } else {
+ toast.error(result.message, {
+ description: result.details ? JSON.stringify(result.details) : "",
+ });
+ if (result.message === "Invalid CAPTCHA") {
+ window.location.reload();
+ }
+ }
+ setIsSubmitting(false);
+ }
+
+ if (isSuccess) {
+ return (
+
+
+
Thank you for your submission!
+
We will get back to you shortly.
+
Redirecting you to our sponsors page...
+
+
+ );
+ }
+
+ return (
+
+
Ready to Sponsor?
+
+ Fill out the form below to get in touch.
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/icon.svg b/apps/web/app/(main)/icon.svg
new file mode 100644
index 000000000..fc49b0f57
--- /dev/null
+++ b/apps/web/app/(main)/icon.svg
@@ -0,0 +1,35 @@
+
diff --git a/apps/web/app/(main)/layout.tsx b/apps/web/app/(main)/layout.tsx
new file mode 100644
index 000000000..feb45f223
--- /dev/null
+++ b/apps/web/app/(main)/layout.tsx
@@ -0,0 +1,236 @@
+import "../globals.css";
+
+import type { Metadata } from "next";
+import {
+ SanityLive,
+ sanityFetch,
+ sanityFetchMetadata,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { Nunito } from "next/font/google";
+import { Inter } from "next/font/google";
+import CookiesProviderClient from "@/components/cookies-provider-client";
+import { Suspense } from "react";
+
+import NextTopLoader from "nextjs-toploader";
+import type { SettingsQueryResult } from "@/sanity/types";
+import * as demo from "@/sanity/lib/demo";
+import { draftMode } from "next/headers";
+import { settingsQuery } from "@/sanity/lib/queries";
+import { cn } from "@/lib/utils";
+import { resolveOpenGraphImage } from "@/sanity/lib/utils";
+import { ThemeProvider } from "@/components/theme-provider";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import NavHeader from "@/components/nav-header";
+import Footer from "@/components/footer";
+import { Toaster } from "@/components/ui/sonner";
+import AlgoliaDialog from "@/components/algolia-dialog";
+import { FaBars } from "react-icons/fa6";
+import PlayerFloating from "@/components/player-floating";
+import { PlayerProvider } from "@/components/player-context";
+import { toPlainText } from "next-sanity";
+import { VisualEditing } from "next-sanity/visual-editing";
+import { DisableDraftMode } from "@/components/disable-draft-mode";
+import { ModeToggle } from "@/components/mode-toggle";
+import { SiteAnalytics } from "@/components/analytics";
+import { JsonLd } from "@/components/json-ld";
+import { SITE_NAME, SITE_URL, absoluteUrl } from "@/lib/site";
+import {
+ buildGraph,
+ organizationSchema,
+ websiteSchema,
+} from "@/lib/structured-data";
+
+const nunito = Nunito({
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-nunito",
+});
+const inter = Inter({
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-inter",
+});
+
+export async function generateMetadata(): Promise {
+ const { perspective } = await getDynamicFetchOptions();
+ const settingsFetch = await sanityFetchMetadata({
+ query: settingsQuery,
+ perspective,
+ });
+
+ const settings = settingsFetch.data as SettingsQueryResult;
+ const title = settings?.title || demo.title;
+ const description = settings?.description || demo.description;
+
+ const ogImage = resolveOpenGraphImage(settings?.ogImage);
+ return {
+ metadataBase: new URL(SITE_URL),
+ title: {
+ template: `%s | ${title}`,
+ default: title,
+ },
+ description: toPlainText(description),
+ openGraph: {
+ images: ogImage ? [ogImage] : [],
+ siteName: SITE_NAME,
+ url: SITE_URL,
+ },
+ alternates: {
+ types: {
+ "application/rss+xml": [
+ { url: "/blog/rss.xml", title: "Blog" },
+ { url: "/podcasts/rss.xml", title: "Podcasts" },
+ ],
+ },
+ },
+ };
+}
+
+async function fetchSettings({
+ perspective,
+ stega,
+}: DynamicFetchOptions): Promise {
+ "use cache";
+ const { data } = await sanityFetch({ query: settingsQuery, perspective, stega });
+ return data as SettingsQueryResult;
+}
+
+async function CachedNavHeader({
+ sideOnly,
+ perspective,
+ stega,
+}: { sideOnly?: boolean } & DynamicFetchOptions) {
+ "use cache";
+ const settings = await fetchSettings({ perspective, stega });
+ return ;
+}
+
+async function DynamicNavHeader({ sideOnly }: { sideOnly?: boolean }) {
+ const { perspective, stega } = await getDynamicFetchOptions();
+ return (
+
+ );
+}
+
+function NavHeaderSlot({
+ sideOnly,
+ isDraftMode,
+}: {
+ sideOnly?: boolean;
+ isDraftMode: boolean;
+}) {
+ // Wrap in Suspense so dynamic routes (e.g. /podcast/preview/[token]) can
+ // produce a static shell — the nav resolves as a streamed/cached hole.
+ return (
+
+ {isDraftMode ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default async function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { isEnabled: isDraftMode } = await draftMode();
+
+ return (
+
+
+
+
+ {isDraftMode && (
+
+
+
+
+ )}
+
+
+
+
+
+ {children}
+
+ {/* Footer NavLinks read usePathname (request data); keep it
+ in a Suspense boundary so dynamic routes can shell-render. */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(main)/meta-pixel.tsx b/apps/web/app/(main)/meta-pixel.tsx
new file mode 100644
index 000000000..4ec4396a4
--- /dev/null
+++ b/apps/web/app/(main)/meta-pixel.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useEffect } from "react";
+import { usePathname, useSearchParams } from "next/navigation";
+
+// biome-ignore lint/style/noNonNullAssertion:
+const FB_PIXEL_ID = process.env.NEXT_PUBLIC_FB_PIXEL_ID!;
+
+export default function MetaPixel() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ import("react-facebook-pixel")
+ .then((x) => x.default)
+ .then((ReactPixel) => {
+ ReactPixel.init(FB_PIXEL_ID);
+ ReactPixel.pageView();
+ });
+ }, []);
+
+ useEffect(() => {
+ import("react-facebook-pixel")
+ .then((x) => x.default)
+ .then((ReactPixel) => {
+ ReactPixel.pageView();
+ });
+ }, [pathname, searchParams]);
+
+ return null;
+}
diff --git a/apps/web/app/(main)/page.tsx b/apps/web/app/(main)/page.tsx
new file mode 100644
index 000000000..ffb5d31da
--- /dev/null
+++ b/apps/web/app/(main)/page.tsx
@@ -0,0 +1,218 @@
+import AnimatedHero from "@/components/animated-hero";
+import PodmatchBadge from "@/components/podmatch-badge";
+
+import CoverImage from "@/components/cover-image";
+import type { HomePageQueryResult } from "@/sanity/types";
+import {
+ sanityFetch,
+ getDynamicFetchOptions,
+ type DynamicFetchOptions,
+} from "@/sanity/lib/live";
+import { homePageQuery } from "@/sanity/lib/queries";
+import Link from "next/link";
+import CoverMedia from "@/components/cover-media";
+import { draftMode } from "next/headers";
+import { Suspense } from "react";
+
+export default async function HomePage() {
+ const { isEnabled: isDraftMode } = await draftMode();
+ if (isDraftMode) {
+ return (
+ }>
+
+
+ );
+ }
+ return ;
+}
+
+async function DynamicHomePage() {
+ const { perspective, stega } = await getDynamicFetchOptions();
+ return ;
+}
+
+async function CachedHomePage({ perspective, stega }: DynamicFetchOptions) {
+ "use cache";
+ const homePageFetch = await sanityFetch({
+ query: homePageQuery,
+ perspective,
+ stega,
+ });
+
+ const homePage = homePageFetch.data as HomePageQueryResult;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Latest Podcasts
+
+
+ Check out our latest podcasts.
+
+
+
+
+ {homePage?.latestPodcasts
+ ?.slice(0, homePage?.latestPodcasts.length / 2)
+ .map((_p, i) => (
+
+ {homePage?.latestPodcasts
+ ?.slice(i * 2, i * 2 + 2)
+ .map((podcast) => (
+
+
+ {podcast.coverImage && (
+
+ )}
+
+
+ {podcast.title}
+
+
+ {podcast.excerpt}
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+
+ Top Podcasts
+
+
+ Check out our latest and greatest podcast episodes covering a
+ wide range of topics in web development.
+
+
+
+
+ {homePage?.topPodcasts
+ ?.slice(0, homePage?.topPodcasts.length / 2)
+ .map((_p, i) => (
+
+ {homePage?.topPodcasts
+ ?.slice(i * 2, i * 2 + 2)
+ .map((podcast) => (
+
+
+ {podcast.coverImage && (
+
+ )}
+
+
+ {podcast.title}
+
+
+ {podcast.excerpt}
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+
+ From the Blog
+
+
+ Check out our latest blog posts on a variety of web
+ development topics.
+
+
+
+
+ {homePage?.latestPosts
+ ?.slice(0, homePage?.latestPosts.length / 2)
+ .map((_p, i) => (
+
+ {homePage?.latestPosts
+ ?.slice(i * 2, i * 2 + 2)
+ .map((post) => (
+
+
+ {post.coverImage && (
+
+ )}
+
+
+ {post.title}
+
+
+ {post.excerpt}
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(sponsor-portal)/layout.tsx b/apps/web/app/(sponsor-portal)/layout.tsx
new file mode 100644
index 000000000..b94d209d0
--- /dev/null
+++ b/apps/web/app/(sponsor-portal)/layout.tsx
@@ -0,0 +1,19 @@
+export default function SponsorPortalLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+ CodingCat.dev — Sponsor Portal
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/apps/web/app/(sponsor-portal)/portal/page.tsx b/apps/web/app/(sponsor-portal)/portal/page.tsx
new file mode 100644
index 000000000..a4c7922bd
--- /dev/null
+++ b/apps/web/app/(sponsor-portal)/portal/page.tsx
@@ -0,0 +1,17 @@
+export default function SponsorPortalPage() {
+ return (
+
+
+ Sponsor Portal
+
+
+ The self-service sponsor portal is coming in Phase 1d. Sponsors will be
+ able to view their campaigns, approve content placements, and track
+ deliverables here.
+
+
+ 🚧 Coming in Phase 1d
+
+
+ )
+}
diff --git a/apps/web/app/api/algolia/route.tsx b/apps/web/app/api/algolia/route.tsx
new file mode 100644
index 000000000..bf0b6ee80
--- /dev/null
+++ b/apps/web/app/api/algolia/route.tsx
@@ -0,0 +1,125 @@
+import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
+import { algoliasearch } from "algoliasearch";
+
+const secret = process.env.PRIVATE_ALGOLIA_WEBOOK_SECRET;
+const algoliaAdminApiKey = process.env.PRIVATE_ALGOLIA_ADMIN_API_KEY;
+const algoliaAppId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
+const algoliaIndex = process.env.NEXT_PUBLIC_ALGOLIA_INDEX;
+
+if (!algoliaAdminApiKey) {
+ throw "Missing PRIVATE_ALGOLIA_ADMIN_API_KEY";
+}
+if (!algoliaAppId) {
+ throw "Missing NEXT_PUBLIC_ALGOLIA_APP_ID";
+}
+if (!algoliaIndex) {
+ throw "Missing NEXT_PUBLIC_ALGOLIA_INDEX";
+}
+
+const client = algoliasearch(algoliaAppId, algoliaAdminApiKey);
+
+function toAlgoliaObject(sanityDoc: any) {
+ const doc = { ...sanityDoc };
+ // biome-ignore lint/performance/noDelete:
+ delete doc.content;
+
+ return (
+ sanityDoc?.content
+ // loop through each block
+ ?.map((block: any, i: number) => {
+ // if it's not a text block with children,
+ // return nothing
+ if (block._type !== "block" || !block.children) {
+ return {
+ ...doc,
+ objectID: `${doc._id}-${i}`,
+ };
+ }
+ // loop through the children spans, and join the
+ // text strings
+ return {
+ ...doc,
+ objectID: `${doc._id}-${i}`,
+ content: block.children.map((child: any) => child.text).join(""),
+ };
+ })
+ );
+}
+
+export async function POST(request: Request) {
+ if (!secret) {
+ return Response.json(
+ { success: false, error: "Missing Secret PRIVATE_ALGOLIA_WEBOOK_SECRET" },
+ { status: 400 },
+ );
+ }
+
+ const signature = request.headers.get(SIGNATURE_HEADER_NAME);
+
+ if (!signature) {
+ return Response.json(
+ { success: false, error: "Missing Signature Header" },
+ { status: 401 },
+ );
+ }
+
+ const body = await request.text();
+ if (!(await isValidSignature(body, signature, secret))) {
+ return Response.json(
+ { success: false, message: "Invalid signature" },
+ { status: 400 },
+ );
+ }
+
+ const sanityDoc = JSON.parse(body);
+
+ // From Sanity Webhook projection
+ // {
+ // ...,
+ // "objectID":_id,
+ // "slug": slug.current,
+ // "guest": null,
+ // "spotify": null,
+ // "created": select(before() == null && after() != null => _id),
+ // "deleted": select(before() != null && after() == null => _id),
+ // "updated": select(before() != null && after() != null => _id),
+ // }
+
+ const created = sanityDoc.created;
+ const deleted = sanityDoc.deleted;
+ const updated = sanityDoc.updated;
+
+ // biome-ignore lint/performance/noDelete:
+ delete sanityDoc.created;
+ // biome-ignore lint/performance/noDelete:
+ delete sanityDoc.deleted;
+ // biome-ignore lint/performance/noDelete:
+ delete sanityDoc.updated;
+
+ const algoliaDoc = toAlgoliaObject(sanityDoc);
+
+ try {
+ if (created) {
+ await client.saveObjects({
+ indexName: algoliaIndex as string,
+ objects: [algoliaDoc],
+ });
+ } else if (updated) {
+ await client.saveObjects({
+ indexName: algoliaIndex as string,
+ objects: [algoliaDoc],
+ });
+ } else {
+ await client.deleteObject({
+ indexName: algoliaIndex as string,
+ objectID: sanityDoc.objectID,
+ });
+ }
+ } catch (e) {
+ const error = JSON.stringify(e);
+ console.error(error);
+ Response.json({ success: false, error }, { status: 400 });
+ }
+
+ return Response.json({ success: true });
+}
diff --git a/apps/web/app/api/auth/logout/route.tsx b/apps/web/app/api/auth/logout/route.tsx
new file mode 100644
index 000000000..ac745f0c0
--- /dev/null
+++ b/apps/web/app/api/auth/logout/route.tsx
@@ -0,0 +1,9 @@
+import { cookies } from "next/headers";
+
+export async function POST(request: Request) {
+ const cookieStore = await cookies();
+ cookieStore.delete("app.at");
+ cookieStore.delete("app.at_exp");
+ cookieStore.delete("app.idt");
+ return Response.redirect(new URL("/", request.url));
+}
diff --git a/apps/web/app/api/auth/session/route.tsx b/apps/web/app/api/auth/session/route.tsx
new file mode 100644
index 000000000..ce1b637dd
--- /dev/null
+++ b/apps/web/app/api/auth/session/route.tsx
@@ -0,0 +1,5 @@
+import type { NextRequest } from "next/server";
+
+export async function POST(request: NextRequest) {
+ return Response.json({ error: "Not found" }, { status: 404 });
+}
diff --git a/apps/web/app/api/auth/session/verify/route.tsx b/apps/web/app/api/auth/session/verify/route.tsx
new file mode 100644
index 000000000..40a76986a
--- /dev/null
+++ b/apps/web/app/api/auth/session/verify/route.tsx
@@ -0,0 +1,5 @@
+import type { NextRequest } from "next/server";
+
+export async function GET(request: NextRequest) {
+ return Response.json({ error: "Not found" }, { status: 404 });
+}
diff --git a/apps/web/app/api/cron/check-renders/route.ts b/apps/web/app/api/cron/check-renders/route.ts
new file mode 100644
index 000000000..5b8c0eda7
--- /dev/null
+++ b/apps/web/app/api/cron/check-renders/route.ts
@@ -0,0 +1,583 @@
+export const maxDuration = 60;
+
+import { type NextRequest } from 'next/server';
+import { after } from 'next/server';
+import { createClient, type SanityClient } from 'next-sanity';
+import { apiVersion, dataset, projectId } from '@/sanity/lib/api';
+import { processVideoProduction } from '@/lib/services/video-pipeline';
+import { checkBothRenders } from '@/lib/services/remotion';
+import { uploadVideoToSanity } from '@/lib/services/sanity-upload';
+import { generateWithGemini } from '@/lib/gemini';
+import { uploadVideo, uploadShort, generateShortsMetadata } from '@/lib/youtube-upload';
+import { notifySubscribers } from '@/lib/resend-notify';
+import { postVideoAnnouncement } from '@/lib/x-social';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface RenderingDoc {
+ _id: string;
+ title?: string;
+ renderData: {
+ mainRenderId: string;
+ shortRenderId: string;
+ bucketName: string;
+ startedAt: string;
+ };
+}
+
+interface ScriptReadyDoc {
+ _id: string;
+ title?: string;
+}
+
+interface VideoGenDoc {
+ _id: string;
+ title?: string;
+}
+
+interface StuckDoc {
+ _id: string;
+ title?: string;
+ _updatedAt: string;
+}
+
+interface RenderProcessResult {
+ id: string;
+ title?: string;
+ status: 'completed' | 'rendering' | 'error';
+ mainProgress?: number;
+ shortProgress?: number;
+ error?: string;
+}
+
+interface AutomatedVideoDoc {
+ _id: string;
+ _type: 'automatedVideo';
+ title: string;
+ script: {
+ hook: string;
+ scenes: Array<{
+ sceneNumber: number;
+ narration: string;
+ visualDescription: string;
+ bRollKeywords: string[];
+ durationEstimate: number;
+ }>;
+ cta: string;
+ };
+ status: string;
+ videoUrl?: string;
+ shortUrl?: string;
+ audioUrl?: string;
+ youtubeId?: string;
+ youtubeShortId?: string;
+ flaggedReason?: string;
+}
+
+interface YouTubeMetadata {
+ title: string;
+ description: string;
+ tags: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Sanity Write Client
+// ---------------------------------------------------------------------------
+
+function getSanityWriteClient(): SanityClient {
+ const token = process.env.SANITY_API_TOKEN || process.env.SANITY_API_WRITE_TOKEN;
+ if (!token) {
+ throw new Error('[PIPELINE] Missing SANITY_API_TOKEN environment variable');
+ }
+ return createClient({ projectId, dataset, apiVersion, token, useCdn: false });
+}
+
+// ---------------------------------------------------------------------------
+// YouTube Metadata Generation (via Gemini)
+// ---------------------------------------------------------------------------
+
+async function generateYouTubeMetadata(doc: AutomatedVideoDoc): Promise {
+ const scriptText = doc.script
+ ? [doc.script.hook, ...(doc.script.scenes?.map((s) => s.narration) ?? []), doc.script.cta].filter(Boolean).join('\n\n')
+ : '';
+
+ const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.
+
+Video Title: ${doc.title}
+Script: ${scriptText}
+
+Generate optimized YouTube metadata for a LONG-FORM video (not Shorts).
+
+Return JSON:
+{
+ "title": "SEO-optimized title, max 100 chars, engaging but not clickbait",
+ "description": "500-1000 chars with key points, timestamps placeholder, channel links, and hashtags",
+ "tags": ["10-15 relevant tags for discoverability"]
+}
+
+Include in the description:
+- Brief summary of what viewers will learn
+- Key topics covered
+- Links section placeholder (🔗 Links mentioned in this video:)
+- Social links placeholder
+- Relevant hashtags at the end`;
+
+ const raw = await generateWithGemini(prompt);
+ try {
+ const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, '').trim()) as YouTubeMetadata;
+ return {
+ title: parsed.title?.slice(0, 100) || doc.title,
+ description: parsed.description || doc.title,
+ tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [],
+ };
+ } catch {
+ return { title: doc.title, description: doc.title, tags: [] };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Distribution Pipeline (extracted from sanity-distribute webhook)
+// ---------------------------------------------------------------------------
+
+async function processDistribution(docId: string): Promise {
+ const client = getSanityWriteClient();
+
+ try {
+ // Fetch full document
+ const doc = await client.fetch(
+ `*[_type == "automatedVideo" && _id == $id][0]`,
+ { id: docId }
+ );
+ if (!doc) throw new Error(`Document not found: ${docId}`);
+
+ // Step 1: Generate YouTube metadata via Gemini
+ console.log(`[PIPELINE] Distribution step 1/6 — Generating YouTube metadata for ${docId}`);
+ const metadata = await generateYouTubeMetadata(doc);
+
+ // Step 2: Upload main video to YouTube
+ let youtubeVideoId = '';
+ if (doc.videoUrl) {
+ console.log(`[PIPELINE] Distribution step 2/6 — Uploading main video for ${docId}`);
+ const r = await uploadVideo({
+ videoUrl: doc.videoUrl,
+ title: metadata.title,
+ description: metadata.description,
+ tags: metadata.tags,
+ });
+ youtubeVideoId = r.videoId;
+ }
+
+ // Step 3: Generate Shorts metadata + upload Short
+ let youtubeShortId = '';
+ if (doc.shortUrl) {
+ console.log(`[PIPELINE] Distribution step 3/6 — Generating Shorts metadata + uploading for ${docId}`);
+ const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc);
+ const r = await uploadShort({
+ videoUrl: doc.shortUrl,
+ title: shortsMetadata.title,
+ description: shortsMetadata.description,
+ tags: shortsMetadata.tags,
+ });
+ youtubeShortId = r.videoId;
+ }
+
+ // Step 4: Email (non-fatal)
+ console.log(`[PIPELINE] Distribution step 4/6 — Sending email for ${docId}`);
+ const ytUrl = youtubeVideoId
+ ? `https://www.youtube.com/watch?v=${youtubeVideoId}`
+ : doc.videoUrl || '';
+ try {
+ await notifySubscribers({
+ subject: `New Video: ${metadata.title}`,
+ videoTitle: metadata.title,
+ videoUrl: ytUrl,
+ description: metadata.description.slice(0, 280),
+ });
+ } catch (e) {
+ console.warn('[PIPELINE] Email error:', e);
+ }
+
+ // Step 5: X/Twitter (non-fatal)
+ console.log(`[PIPELINE] Distribution step 5/6 — Posting to X/Twitter for ${docId}`);
+ try {
+ await postVideoAnnouncement({
+ videoTitle: metadata.title,
+ youtubeUrl: ytUrl,
+ tags: metadata.tags,
+ });
+ } catch (e) {
+ console.warn('[PIPELINE] X/Twitter error:', e);
+ }
+
+ // Step 6: Mark published
+ console.log(`[PIPELINE] Distribution step 6/6 — Marking published for ${docId}`);
+ await client.patch(docId).set({
+ status: 'published',
+ youtubeId: youtubeVideoId || undefined,
+ youtubeShortId: youtubeShortId || undefined,
+ }).commit();
+
+ console.log(`[PIPELINE] ✅ Distribution complete for ${docId}`);
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ console.error(`[PIPELINE] ❌ Distribution failed for ${docId}: ${msg}`);
+ try {
+ await client.patch(docId).set({
+ status: 'flagged',
+ flaggedReason: `Distribution error: ${msg}`,
+ }).commit();
+ } catch { /* best-effort flag */ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Handler 1: script_ready → audio_gen (claim) → video pipeline via after()
+// ---------------------------------------------------------------------------
+
+async function handleScriptReady(client: SanityClient): Promise<{ claimed: number; ids: string[] }> {
+ const docs = await client.fetch(
+ `*[_type == "automatedVideo" && status == "script_ready"]{ _id, title }`
+ );
+
+ if (docs.length === 0) return { claimed: 0, ids: [] };
+
+ const claimedIds: string[] = [];
+
+ for (const doc of docs) {
+ console.log(`[PIPELINE] Claiming script_ready → audio_gen: "${doc.title || doc._id}"`);
+ try {
+ // CLAIM: immediately advance status so next cron run skips this doc
+ await client.patch(doc._id).set({ status: 'audio_gen' }).commit();
+ claimedIds.push(doc._id);
+
+ // WORK: run video production in background via after()
+ after(async () => {
+ try {
+ console.log(`[PIPELINE] Starting video production for ${doc._id}`);
+ await processVideoProduction(doc._id);
+ console.log(`[PIPELINE] ✅ Video production complete for ${doc._id}`);
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ console.error(`[PIPELINE] ❌ Video production failed for ${doc._id}: ${msg}`);
+ // processVideoProduction already sets flagged on error, but just in case:
+ try {
+ const c = getSanityWriteClient();
+ await c.patch(doc._id).set({
+ status: 'flagged',
+ flaggedReason: `Video production error: ${msg}`,
+ }).commit();
+ } catch { /* best-effort */ }
+ }
+ });
+ } catch (error) {
+ console.error(`[PIPELINE] Failed to claim ${doc._id}:`, error);
+ }
+ }
+
+ return { claimed: claimedIds.length, ids: claimedIds };
+}
+
+// ---------------------------------------------------------------------------
+// Handler 2: rendering → check Lambda progress → video_gen
+// ---------------------------------------------------------------------------
+
+async function handleRendering(client: SanityClient): Promise<{
+ completed: number;
+ inProgress: number;
+ errors: number;
+ results: RenderProcessResult[];
+}> {
+ const docs = await client.fetch(
+ `*[_type == "automatedVideo" && status == "rendering" && defined(renderData)]{
+ _id, title, renderData
+ }`
+ );
+
+ if (docs.length === 0) return { completed: 0, inProgress: 0, errors: 0, results: [] };
+
+ const results: RenderProcessResult[] = [];
+ let completed = 0;
+ let inProgress = 0;
+ let errors = 0;
+
+ for (const doc of docs) {
+ try {
+ console.log(`[PIPELINE] Checking renders for "${doc.title || doc._id}"...`);
+
+ const progress = await checkBothRenders(
+ doc.renderData.mainRenderId,
+ doc.renderData.shortRenderId,
+ doc.renderData.bucketName
+ );
+
+ // Check for render errors
+ if (progress.main.errors || progress.short.errors) {
+ const errorMsg = [progress.main.errors, progress.short.errors]
+ .filter(Boolean)
+ .join('; ');
+ console.error(`[PIPELINE] Render error for ${doc._id}: ${errorMsg}`);
+
+ await client.patch(doc._id).set({
+ status: 'flagged',
+ flaggedReason: `Remotion render failed: ${errorMsg}`,
+ }).commit();
+
+ errors++;
+ results.push({ id: doc._id, title: doc.title, status: 'error', error: errorMsg });
+ continue;
+ }
+
+ if (progress.allDone) {
+ console.log(`[PIPELINE] Both renders done for "${doc.title || doc._id}", downloading...`);
+
+ // Download rendered videos from Remotion S3
+ const [mainVideoResponse, shortVideoResponse] = await Promise.all([
+ fetch(progress.main.outputUrl!),
+ fetch(progress.short.outputUrl!),
+ ]);
+
+ if (!mainVideoResponse.ok) {
+ throw new Error(`Failed to download main video: ${mainVideoResponse.status}`);
+ }
+ if (!shortVideoResponse.ok) {
+ throw new Error(`Failed to download short video: ${shortVideoResponse.status}`);
+ }
+
+ const [mainVideoBuffer, shortVideoBuffer] = await Promise.all([
+ Buffer.from(await mainVideoResponse.arrayBuffer()),
+ Buffer.from(await shortVideoResponse.arrayBuffer()),
+ ]);
+
+ console.log(
+ `[PIPELINE] Downloaded — main: ${mainVideoBuffer.length} bytes, short: ${shortVideoBuffer.length} bytes`
+ );
+
+ // Upload to Sanity
+ const [mainUploadResult, shortUploadResult] = await Promise.all([
+ uploadVideoToSanity(mainVideoBuffer, `${doc._id}-main.mp4`),
+ uploadVideoToSanity(shortVideoBuffer, `${doc._id}-short.mp4`),
+ ]);
+
+ console.log(
+ `[PIPELINE] Uploaded — main: ${mainUploadResult.url}, short: ${shortUploadResult.url}`
+ );
+
+ // Update Sanity document with video URLs and advance to video_gen
+ await client.patch(doc._id).set({
+ status: 'video_gen',
+ videoUrl: mainUploadResult.url,
+ videoFile: {
+ _type: 'file',
+ asset: { _type: 'reference', _ref: mainUploadResult.assetId },
+ },
+ shortUrl: shortUploadResult.url,
+ shortFile: {
+ _type: 'file',
+ asset: { _type: 'reference', _ref: shortUploadResult.assetId },
+ },
+ }).commit();
+
+ console.log(`[PIPELINE] ✅ ${doc._id} → video_gen`);
+ completed++;
+ results.push({ id: doc._id, title: doc.title, status: 'completed' });
+ } else {
+ console.log(
+ `[PIPELINE] Still rendering "${doc.title || doc._id}" — ` +
+ `main: ${progress.main.progress}%, short: ${progress.short.progress}%`
+ );
+ inProgress++;
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ status: 'rendering',
+ mainProgress: progress.main.progress,
+ shortProgress: progress.short.progress,
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error(`[PIPELINE] ❌ Error processing ${doc._id}: ${errorMessage}`);
+
+ try {
+ await client.patch(doc._id).set({
+ status: 'flagged',
+ flaggedReason: `check-renders error: ${errorMessage}`,
+ }).commit();
+ } catch (patchError) {
+ console.error(`[PIPELINE] Failed to flag ${doc._id}:`, patchError);
+ }
+
+ errors++;
+ results.push({ id: doc._id, title: doc.title, status: 'error', error: errorMessage });
+ }
+ }
+
+ return { completed, inProgress, errors, results };
+}
+
+// ---------------------------------------------------------------------------
+// Handler 3: video_gen → uploading (claim) → distribution via after()
+// ---------------------------------------------------------------------------
+
+async function handleVideoGen(client: SanityClient): Promise<{ claimed: number; ids: string[] }> {
+ const docs = await client.fetch(
+ `*[_type == "automatedVideo" && status == "video_gen"]{ _id, title }`
+ );
+
+ if (docs.length === 0) return { claimed: 0, ids: [] };
+
+ const claimedIds: string[] = [];
+
+ for (const doc of docs) {
+ console.log(`[PIPELINE] Claiming video_gen → uploading: "${doc.title || doc._id}"`);
+ try {
+ // CLAIM: immediately advance status so next cron run skips this doc
+ await client.patch(doc._id).set({ status: 'uploading' }).commit();
+ claimedIds.push(doc._id);
+
+ // WORK: run distribution in background via after()
+ after(async () => {
+ try {
+ console.log(`[PIPELINE] Starting distribution for ${doc._id}`);
+ await processDistribution(doc._id);
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ console.error(`[PIPELINE] ❌ Distribution after() failed for ${doc._id}: ${msg}`);
+ }
+ });
+ } catch (error) {
+ console.error(`[PIPELINE] Failed to claim ${doc._id}:`, error);
+ }
+ }
+
+ return { claimed: claimedIds.length, ids: claimedIds };
+}
+
+// ---------------------------------------------------------------------------
+// Handler 4: Stuck detection
+// ---------------------------------------------------------------------------
+
+async function handleStuckDocs(client: SanityClient): Promise<{ audioGen: number; rendering: number }> {
+ let audioGenFlagged = 0;
+ let renderingFlagged = 0;
+
+ // audio_gen stuck > 10 minutes
+ const stuckAudioGen = await client.fetch(
+ `*[_type == "automatedVideo" && status == "audio_gen" && dateTime(_updatedAt) < dateTime(now()) - 60*10]{
+ _id, title, _updatedAt
+ }`
+ );
+
+ for (const doc of stuckAudioGen) {
+ console.log(`[PIPELINE] Flagging stuck audio_gen: "${doc.title || doc._id}" (since ${doc._updatedAt})`);
+ try {
+ await client.patch(doc._id).set({
+ status: 'flagged',
+ flaggedReason: `Pipeline timed out during audio generation. Stuck in audio_gen since ${doc._updatedAt}. Reset status to script_ready to retry.`,
+ }).commit();
+ audioGenFlagged++;
+ } catch (err) {
+ console.error(`[PIPELINE] Failed to flag stuck audio_gen doc ${doc._id}:`, err);
+ }
+ }
+
+ // rendering stuck > 30 minutes
+ const stuckRendering = await client.fetch(
+ `*[_type == "automatedVideo" && status == "rendering" && dateTime(_updatedAt) < dateTime(now()) - 60*30]{
+ _id, title, _updatedAt
+ }`
+ );
+
+ for (const doc of stuckRendering) {
+ console.log(`[PIPELINE] Flagging stuck rendering: "${doc.title || doc._id}" (since ${doc._updatedAt})`);
+ try {
+ await client.patch(doc._id).set({
+ status: 'flagged',
+ flaggedReason: `Render timed out. Stuck in rendering since ${doc._updatedAt}. Reset status to script_ready to retry.`,
+ }).commit();
+ renderingFlagged++;
+ } catch (err) {
+ console.error(`[PIPELINE] Failed to flag stuck rendering doc ${doc._id}:`, err);
+ }
+ }
+
+ return { audioGen: audioGenFlagged, rendering: renderingFlagged };
+}
+
+// ---------------------------------------------------------------------------
+// Route Handler — Unified Pipeline Cron
+// ---------------------------------------------------------------------------
+
+/**
+ * Unified pipeline cron — the single driver for ALL status transitions.
+ * Replaces the sanity-content and sanity-distribute webhooks.
+ *
+ * Runs every 1-2 minutes via Supabase cron. Uses "claim first, work second"
+ * pattern to prevent duplicate processing on overlapping runs.
+ *
+ * Status transitions handled:
+ * script_ready → audio_gen (claim) → video pipeline via after()
+ * rendering → check Lambda → video_gen (or flagged)
+ * video_gen → uploading (claim) → distribution via after()
+ * audio_gen stuck >10min → flagged
+ * rendering stuck >30min → flagged
+ */
+export async function GET(request: NextRequest) {
+ // Auth check
+ const cronSecret = process.env.CRON_SECRET;
+ if (!cronSecret) {
+ console.error('[PIPELINE] CRON_SECRET not configured');
+ return Response.json({ error: 'Server misconfigured' }, { status: 503 });
+ }
+ const authHeader = request.headers.get('authorization');
+ if (authHeader !== `Bearer ${cronSecret}`) {
+ console.error('[PIPELINE] Unauthorized cron request');
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ console.log('[PIPELINE] ⏰ Unified cron starting...');
+
+ const client = getSanityWriteClient();
+
+ // Run all handlers in parallel where safe
+ // Note: script_ready and video_gen use after() so they return quickly
+ const [scriptReady, rendering, videoGen, stuckFlagged] = await Promise.all([
+ handleScriptReady(client),
+ handleRendering(client),
+ handleVideoGen(client),
+ handleStuckDocs(client),
+ ]);
+
+ const summary = {
+ scriptReady,
+ rendering: {
+ completed: rendering.completed,
+ inProgress: rendering.inProgress,
+ errors: rendering.errors,
+ results: rendering.results,
+ },
+ videoGen,
+ stuckFlagged,
+ timestamp: new Date().toISOString(),
+ };
+
+ const totalActions =
+ scriptReady.claimed +
+ rendering.completed +
+ rendering.errors +
+ videoGen.claimed +
+ stuckFlagged.audioGen +
+ stuckFlagged.rendering;
+
+ if (totalActions > 0) {
+ console.log(`[PIPELINE] ⏰ Cron complete — ${totalActions} actions taken`, JSON.stringify(summary, null, 2));
+ } else if (rendering.inProgress > 0) {
+ console.log(`[PIPELINE] ⏰ Cron complete — ${rendering.inProgress} renders in progress`);
+ } else {
+ console.log('[PIPELINE] ⏰ Cron complete — nothing to do');
+ }
+
+ return Response.json(summary);
+}
diff --git a/apps/web/app/api/cron/check-research/route.ts b/apps/web/app/api/cron/check-research/route.ts
new file mode 100644
index 000000000..9cdb0a564
--- /dev/null
+++ b/apps/web/app/api/cron/check-research/route.ts
@@ -0,0 +1,1083 @@
+export const maxDuration = 60;
+
+import { type NextRequest } from 'next/server';
+import { createClient, type SanityClient } from 'next-sanity';
+import { apiVersion, dataset, projectId } from '@/sanity/lib/api';
+import { pollResearch, parseResearchReport } from '@/lib/services/gemini-research';
+import { generateInfographicsForTopic, generateFromScenePrompts } from '@/lib/services/gemini-infographics';
+import { generateWithGemini, stripCodeFences } from '@/lib/gemini';
+import { getConfigValue } from '@/lib/config';
+import type { ResearchPayload } from '@/lib/services/research';
+import { writeClient } from '@/lib/sanity-write-client';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface PipelineDoc {
+ _id: string;
+ title: string;
+ status: string;
+ researchInteractionId?: string;
+ researchNotebookId?: string;
+ trendScore?: number;
+ trendSources?: string;
+ script?: {
+ hook: string;
+ scenes: Array<{
+ _key: string;
+ sceneNumber: number;
+ sceneType: string;
+ narration: string;
+ visualDescription: string;
+ bRollKeywords: string[];
+ durationEstimate: number;
+ imagePrompts?: string[];
+ code?: { snippet: string; language: string; highlightLines?: number[] };
+ list?: { items: string[]; icon?: string };
+ comparison?: {
+ leftLabel: string;
+ rightLabel: string;
+ rows: { left: string; right: string }[];
+ };
+ mockup?: { deviceType: string; screenContent: string };
+ }>;
+ cta: string;
+ };
+ researchData?: string;
+ infographicProgress?: {
+ completed: number;
+ total: number;
+ horizontalRefs?: Array<{ _type: 'image'; _key: string; alt?: string; asset: { _type: 'reference'; _ref: string } }>;
+ verticalRefs?: Array<{ _type: 'image'; _key: string; alt?: string; asset: { _type: 'reference'; _ref: string } }>;
+ infographicUrls?: string[];
+ verticalUrls?: string[];
+ };
+ _updatedAt: string;
+}
+
+interface EnrichedScript {
+ title: string;
+ summary: string;
+ sourceUrl: string;
+ topics: string[];
+ script: {
+ hook: string;
+ scenes: Array<{
+ sceneNumber: number;
+ sceneType: string;
+ narration: string;
+ visualDescription: string;
+ bRollKeywords: string[];
+ durationEstimate: number;
+ imagePrompts?: string[];
+ code?: { snippet: string; language: string; highlightLines?: number[] };
+ list?: { items: string[]; icon?: string };
+ comparison?: {
+ leftLabel: string;
+ rightLabel: string;
+ rows: { left: string; right: string }[];
+ };
+ mockup?: { deviceType: string; screenContent: string };
+ }>;
+ cta: string;
+ };
+ qualityScore: number;
+}
+
+interface CriticResult {
+ score: number;
+ issues: string[];
+ summary: string;
+}
+
+interface StepResult {
+ id: string;
+ title: string;
+ step: string;
+ outcome: string;
+ error?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/** Build stuck thresholds from config (with fallbacks) */
+async function buildStuckThresholds(): Promise> {
+ const stuckMinutes = await getConfigValue('pipeline_config', 'stuckTimeoutMinutes', 30);
+ return {
+ researching: stuckMinutes * 60 * 1000,
+ infographics_generating: Math.round(stuckMinutes * 0.5) * 60 * 1000, // half the main timeout
+ enriching: Math.round(stuckMinutes * 0.33) * 60 * 1000, // third of main timeout
+ };
+}
+
+/** Max docs to process per status per run — keeps total time well under 60s */
+const MAX_DOCS_PER_STATUS = 2;
+
+/** Max prompts to process per cron cycle (each prompt = 2 Imagen calls) */
+const INFOGRAPHIC_BATCH_SIZE = 1; // MVP: 1 prompt per cycle (2 Gemini calls ~24s, fits in 60s Hobby)
+const MAX_INFOGRAPHIC_PROMPTS = 4; // MVP: limit total infographics (4 prompts × 2 orientations = 8 images)
+
+// ---------------------------------------------------------------------------
+// Sanity Write Client
+// ---------------------------------------------------------------------------
+
+function getSanityWriteClient(): SanityClient {
+ const token = process.env.SANITY_API_TOKEN || process.env.SANITY_API_WRITE_TOKEN;
+ if (!token) {
+ throw new Error('[check-research] Missing SANITY_API_TOKEN environment variable');
+ }
+ return createClient({ projectId, dataset, apiVersion, token, useCdn: false });
+}
+
+// ---------------------------------------------------------------------------
+// Stuck detection — runs FIRST, no external API calls needed
+// ---------------------------------------------------------------------------
+
+async function flagStuckDocs(
+ docs: PipelineDoc[],
+ sanity: SanityClient,
+ stuckThresholds: Record,
+): Promise {
+ const results: StepResult[] = [];
+ const now = Date.now();
+
+ for (const doc of docs) {
+ const threshold = stuckThresholds[doc.status];
+ if (!threshold) continue;
+
+ const docAge = now - new Date(doc._updatedAt).getTime();
+ if (docAge > threshold) {
+ const ageMin = Math.round(docAge / 60_000);
+ console.warn(
+ `[check-research] Doc ${doc._id} ("${doc.title}") stuck in "${doc.status}" for ${ageMin}min — flagging`,
+ );
+ try {
+ await sanity
+ .patch(doc._id)
+ .set({
+ status: 'flagged',
+ flaggedReason: `Stuck in "${doc.status}" for ${ageMin} minutes (threshold: ${Math.round(threshold / 60_000)}min). May need manual intervention.`,
+ })
+ .commit();
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ step: 'stuck-detection',
+ outcome: 'flagged',
+ error: `Stuck in "${doc.status}" for ${ageMin}min`,
+ });
+ } catch (err) {
+ console.error(`[check-research] Failed to flag stuck doc ${doc._id}:`, err);
+ }
+ }
+ }
+
+ return results;
+}
+
+// ---------------------------------------------------------------------------
+// Step 1: researching → research_complete (Gemini Deep Research polling)
+// ---------------------------------------------------------------------------
+
+async function stepResearching(
+ doc: PipelineDoc,
+ sanity: SanityClient,
+): Promise {
+ // Use researchInteractionId (new Gemini) or fall back to researchNotebookId (legacy)
+ const interactionId = doc.researchInteractionId;
+
+ if (!interactionId) {
+ // Legacy doc without interaction ID — skip to enriching with existing data
+ console.warn(`[check-research] No researchInteractionId for "${doc.title}" — skipping to enriching`);
+ await sanity.patch(doc._id).set({ status: 'enriching' }).commit();
+ return { id: doc._id, title: doc.title, step: 'researching', outcome: 'no_interaction_skip_to_enriching' };
+ }
+
+ console.log(`[check-research] Polling research for "${doc.title}" (interaction: ${interactionId})`);
+
+ const result = await pollResearch(interactionId);
+
+ if (result.status === 'in_progress') {
+ return { id: doc._id, title: doc.title, step: 'researching', outcome: 'still_in_progress' };
+ }
+
+ if (result.status === 'failed' || result.status === 'not_found') {
+ console.error(`[check-research] Research ${result.status} for "${doc.title}": ${result.error}`);
+ await sanity.patch(doc._id).set({
+ status: 'flagged',
+ flaggedReason: `Research ${result.status}: ${result.error || 'Unknown error'}`,
+ }).commit();
+ return { id: doc._id, title: doc.title, step: 'researching', outcome: result.status, error: result.error };
+ }
+
+ // Research completed — parse the report into structured data
+ const report = result.report || '';
+ console.log(`[check-research] Research completed for "${doc.title}" (${report.length} chars)`);
+
+ const researchPayload = await parseResearchReport(doc.title, report);
+
+ // Save research data and advance to research_complete
+ await sanity.patch(doc._id).set({
+ status: 'research_complete',
+ researchData: JSON.stringify(researchPayload),
+ }).commit();
+
+ console.log(`[check-research] "${doc.title}" → research_complete`);
+ return { id: doc._id, title: doc.title, step: 'researching', outcome: 'research_complete' };
+}
+
+// ---------------------------------------------------------------------------
+// Step 2: research_complete → enriching (Gemini Imagen infographics)
+// ---------------------------------------------------------------------------
+
+async function stepResearchComplete(
+ doc: PipelineDoc,
+ sanity: SanityClient,
+): Promise {
+ console.log(`[check-research] Setting up infographic generation for "${doc.title}"`);
+
+ // Collect imagePrompts from script scenes
+ const scenePromptMap: Array<{ sceneNumber: number; promptCount: number }> = [];
+ const sceneImagePrompts: string[] = [];
+ if (doc.script?.scenes) {
+ for (const scene of doc.script.scenes) {
+ if (scene.imagePrompts && Array.isArray(scene.imagePrompts)) {
+ scenePromptMap.push({ sceneNumber: scene.sceneNumber, promptCount: scene.imagePrompts.length });
+ sceneImagePrompts.push(...scene.imagePrompts);
+ }
+ }
+ }
+
+ // MVP: cap total prompts
+ if (sceneImagePrompts.length > MAX_INFOGRAPHIC_PROMPTS) {
+ sceneImagePrompts.length = MAX_INFOGRAPHIC_PROMPTS;
+ }
+
+ const enableHorizontal = await getConfigValue('pipeline_config', 'enableHorizontalInfographics', false);
+ const multiplier = enableHorizontal ? 2 : 1; // 2 orientations or vertical only
+ const total = sceneImagePrompts.length > 0
+ ? sceneImagePrompts.length * multiplier
+ : 5 * multiplier; // fallback: 5 topic-level
+
+ await sanity.patch(doc._id).set({
+ status: 'infographics_generating',
+ infographicProgress: { completed: 0, total },
+ }).commit();
+
+ console.log(`[check-research] "${doc.title}" -> infographics_generating (0/${total})`);
+ return { id: doc._id, title: doc.title, step: 'research_complete', outcome: 'infographics_generating' };
+}
+
+// ---------------------------------------------------------------------------
+// Step 3: infographics_generating -> enriching (BATCHED)
+// ---------------------------------------------------------------------------
+
+async function stepInfographicsGenerating(
+ doc: PipelineDoc,
+ sanity: SanityClient,
+): Promise {
+ const progress = doc.infographicProgress;
+ if (!progress) {
+ // Legacy doc or missing progress - just advance
+ console.warn(`[check-research] No infographicProgress for "${doc.title}" - advancing to enriching`);
+ await sanity.patch(doc._id).set({ status: 'enriching' }).commit();
+ return { id: doc._id, title: doc.title, step: 'infographics_generating', outcome: 'no_progress_skip' };
+ }
+
+ // Parse research data for briefing context (needed for fallback path)
+ let briefing = '';
+ if (doc.researchData) {
+ try {
+ const data = JSON.parse(doc.researchData) as { briefing?: string };
+ briefing = data.briefing || '';
+ } catch { /* ignore */ }
+ }
+
+ // Collect all image prompts
+ const scenePromptMap: Array<{ sceneNumber: number; promptCount: number }> = [];
+ const sceneImagePrompts: string[] = [];
+ if (doc.script?.scenes) {
+ for (const scene of doc.script.scenes) {
+ if (scene.imagePrompts && Array.isArray(scene.imagePrompts)) {
+ scenePromptMap.push({ sceneNumber: scene.sceneNumber, promptCount: scene.imagePrompts.length });
+ sceneImagePrompts.push(...scene.imagePrompts);
+ }
+ }
+ }
+ // MVP: cap total prompts
+ if (sceneImagePrompts.length > MAX_INFOGRAPHIC_PROMPTS) {
+ sceneImagePrompts.length = MAX_INFOGRAPHIC_PROMPTS;
+ }
+
+ // Determine which prompts to process this cycle
+ // Divisor matches multiplier from stepResearchComplete: 2 for both orientations, 1 for vertical-only
+ const enableHorizontalForCompletion = await getConfigValue('pipeline_config', 'enableHorizontalInfographics', false);
+ const divisor = enableHorizontalForCompletion ? 2 : 1;
+ const completedPrompts = Math.floor(progress.completed / divisor);
+ const useScenePrompts = sceneImagePrompts.length > 0;
+
+ let batchPrompts: string[];
+ if (useScenePrompts) {
+ batchPrompts = sceneImagePrompts.slice(completedPrompts, completedPrompts + INFOGRAPHIC_BATCH_SIZE);
+ } else {
+ // Fallback: generate topic-level prompts (only 5 images, fits in one cycle)
+ const batchResult = await generateInfographicsForTopic(doc.title, briefing);
+
+ // Upload fallback results
+ const horizontalRefs = [...(progress.horizontalRefs || [])];
+ const infographicUrls = [...(progress.infographicUrls || [])];
+
+ for (let i = 0; i < batchResult.results.length; i++) {
+ const imgResult = batchResult.results[i];
+ try {
+ const buffer = Buffer.from(imgResult.imageBase64, 'base64');
+ const filename = `infographic-${doc._id}-${i}.png`;
+ const asset = await writeClient.assets.upload('image', buffer, {
+ filename, contentType: imgResult.mimeType,
+ });
+ horizontalRefs.push({
+ _type: 'image', _key: `infographic-${i}`,
+ alt: `Research infographic ${i + 1} for ${doc.title}`,
+ asset: { _type: 'reference', _ref: asset._id },
+ });
+ const cdnUrl = `https://cdn.sanity.io/images/${projectId}/${dataset}/${asset._id.replace('image-', '').replace('-png', '.png').replace('-jpg', '.jpg')}`;
+ infographicUrls.push(cdnUrl);
+ } catch (err) {
+ console.warn(`[check-research] Failed to upload fallback infographic ${i}:`, err instanceof Error ? err.message : err);
+ }
+ }
+
+ // Fallback is done in one cycle - finalize
+ await finalizeInfographics(doc, sanity, horizontalRefs, [], infographicUrls, [], scenePromptMap);
+ return { id: doc._id, title: doc.title, step: 'infographics_generating', outcome: 'enriching' };
+ }
+
+ if (batchPrompts.length === 0) {
+ // All prompts processed - finalize
+ await finalizeInfographics(
+ doc, sanity,
+ progress.horizontalRefs || [],
+ progress.verticalRefs || [],
+ progress.infographicUrls || [],
+ progress.verticalUrls || [],
+ scenePromptMap,
+ );
+ return { id: doc._id, title: doc.title, step: 'infographics_generating', outcome: 'enriching' };
+ }
+
+ console.log(`[check-research] Generating batch: prompts ${completedPrompts + 1}-${completedPrompts + batchPrompts.length} of ${sceneImagePrompts.length} for "${doc.title}"`);
+
+ // Check if horizontal infographics are enabled
+ const enableHorizontal = await getConfigValue('pipeline_config', 'enableHorizontalInfographics', false);
+
+ // Generate this batch using the existing function (but only for the batch)
+ const batchResult = await generateFromScenePrompts(batchPrompts, doc.title, { skipHorizontal: !enableHorizontal });
+
+ // Accumulate refs from previous cycles
+ const horizontalRefs = [...(progress.horizontalRefs || [])];
+ const verticalRefs = [...(progress.verticalRefs || [])];
+ const infographicUrls = [...(progress.infographicUrls || [])];
+ const verticalUrls = [...(progress.verticalUrls || [])];
+
+ // Upload horizontal images
+ for (let i = 0; i < batchResult.horizontal.length; i++) {
+ const imgResult = batchResult.horizontal[i];
+ const globalIndex = horizontalRefs.length;
+ try {
+ const buffer = Buffer.from(imgResult.imageBase64, 'base64');
+ const filename = `infographic-h-${doc._id}-${globalIndex}.png`;
+ const asset = await writeClient.assets.upload('image', buffer, {
+ filename, contentType: imgResult.mimeType,
+ });
+ horizontalRefs.push({
+ _type: 'image', _key: `h-${globalIndex}`,
+ alt: `Infographic ${globalIndex + 1} for ${doc.title}`,
+ asset: { _type: 'reference', _ref: asset._id },
+ });
+ const cdnUrl = `https://cdn.sanity.io/images/${projectId}/${dataset}/${asset._id.replace('image-', '').replace('-png', '.png').replace('-jpg', '.jpg')}`;
+ infographicUrls.push(cdnUrl);
+ } catch (err) {
+ console.warn(`[check-research] Failed to upload horizontal infographic ${globalIndex}:`, err instanceof Error ? err.message : err);
+ }
+ }
+
+ // Upload vertical images
+ for (let i = 0; i < batchResult.vertical.length; i++) {
+ const imgResult = batchResult.vertical[i];
+ const globalIndex = verticalRefs.length;
+ try {
+ const buffer = Buffer.from(imgResult.imageBase64, 'base64');
+ const filename = `infographic-v-${doc._id}-${globalIndex}.png`;
+ const asset = await writeClient.assets.upload('image', buffer, {
+ filename, contentType: imgResult.mimeType,
+ });
+ verticalRefs.push({
+ _type: 'image', _key: `v-${globalIndex}`,
+ alt: `Infographic vertical ${globalIndex + 1} for ${doc.title}`,
+ asset: { _type: 'reference', _ref: asset._id },
+ });
+ const cdnUrl = `https://cdn.sanity.io/images/${projectId}/${dataset}/${asset._id.replace('image-', '').replace('-png', '.png').replace('-jpg', '.jpg')}`;
+ verticalUrls.push(cdnUrl);
+ } catch (err) {
+ console.warn(`[check-research] Failed to upload vertical infographic ${globalIndex}:`, err instanceof Error ? err.message : err);
+ }
+ }
+
+ const newCompleted = (horizontalRefs.length + verticalRefs.length);
+ const isComplete = completedPrompts + batchPrompts.length >= sceneImagePrompts.length;
+
+ if (isComplete) {
+ // All batches done - finalize
+ await finalizeInfographics(doc, sanity, horizontalRefs, verticalRefs, infographicUrls, verticalUrls, scenePromptMap);
+ console.log(`[check-research] "${doc.title}" -> enriching (${horizontalRefs.length}H + ${verticalRefs.length}V infographics)`);
+ return { id: doc._id, title: doc.title, step: 'infographics_generating', outcome: 'enriching' };
+ } else {
+ // More batches needed - save progress
+ await sanity.patch(doc._id).set({
+ infographicProgress: {
+ completed: newCompleted,
+ total: progress.total,
+ horizontalRefs,
+ verticalRefs,
+ infographicUrls,
+ verticalUrls,
+ },
+ }).commit();
+ console.log(`[check-research] "${doc.title}" batch complete: ${newCompleted}/${progress.total} images`);
+ return { id: doc._id, title: doc.title, step: 'infographics_generating', outcome: `batch_${newCompleted}_of_${progress.total}` };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helper: Finalize infographics after all batches complete
+// ---------------------------------------------------------------------------
+
+async function finalizeInfographics(
+ doc: PipelineDoc,
+ sanity: SanityClient,
+ horizontalRefs: Array<{ _type: 'image'; _key: string; alt?: string; asset: { _type: 'reference'; _ref: string } }>,
+ verticalRefs: Array<{ _type: 'image'; _key: string; alt?: string; asset: { _type: 'reference'; _ref: string } }>,
+ infographicUrls: string[],
+ verticalUrls: string[],
+ scenePromptMap: Array<{ sceneNumber: number; promptCount: number }>,
+): Promise {
+ // Update research data with infographic URLs
+ let researchData: Record = {};
+ if (doc.researchData) {
+ try { researchData = JSON.parse(doc.researchData); } catch { /* ignore */ }
+ }
+ researchData.infographicUrls = infographicUrls;
+ if (verticalUrls.length > 0) {
+ researchData.infographicVerticalUrls = verticalUrls;
+ }
+
+ const patchData: Record = {
+ status: 'enriching',
+ researchData: JSON.stringify(researchData),
+ infographicProgress: null, // Clear progress tracking
+ };
+ if (horizontalRefs.length > 0) {
+ patchData.infographicsHorizontal = horizontalRefs;
+ }
+ if (verticalRefs.length > 0) {
+ patchData.infographicsVertical = verticalRefs;
+ }
+ // Backward compat — 'infographics' is the primary field video-pipeline reads
+ // Use horizontal if available, otherwise fall back to vertical
+ if (horizontalRefs.length > 0) {
+ patchData.infographics = horizontalRefs;
+ } else if (verticalRefs.length > 0) {
+ patchData.infographics = verticalRefs;
+ }
+
+ // Distribute infographic URLs back to scene-level for Remotion
+ // Use horizontal URLs if available, otherwise fall back to vertical
+ const availableUrls = infographicUrls.length > 0 ? infographicUrls : verticalUrls;
+ if (doc.script?.scenes && availableUrls.length > 0) {
+ let urlIndex = 0;
+ const updatedScenes = doc.script.scenes.map((scene) => {
+ const mapping = scenePromptMap.find(m => m.sceneNumber === scene.sceneNumber);
+ if (mapping && mapping.promptCount > 0) {
+ const sceneUrls = availableUrls.slice(urlIndex, urlIndex + mapping.promptCount);
+ urlIndex += mapping.promptCount;
+ return { ...scene, infographicUrls: sceneUrls };
+ }
+ return scene;
+ });
+ patchData['script'] = { ...doc.script, scenes: updatedScenes };
+ }
+
+ await sanity.patch(doc._id).set(patchData).commit();
+}
+
+// ---------------------------------------------------------------------------
+// Step 4: enriching → script_ready
+// ---------------------------------------------------------------------------
+
+async function stepEnriching(
+ doc: PipelineDoc,
+ sanity: SanityClient,
+): Promise {
+ console.log(`[check-research] Step 4: Enriching script for "${doc.title}"`);
+
+ // Parse research data from Sanity
+ let researchData: Record = {};
+
+ if (doc.researchData) {
+ try {
+ researchData = JSON.parse(doc.researchData) as Record;
+ } catch {
+ console.warn(`[check-research] Failed to parse researchData for "${doc.title}"`);
+ }
+ }
+
+ // Build full research payload
+ const researchPayload = buildResearchPayload(doc, researchData);
+
+ // Generate enriched script with Gemini
+ let enrichedScript: EnrichedScript | null = null;
+ try {
+ const SYSTEM_INSTRUCTION = await getConfigValue(
+ 'content_config',
+ 'systemInstruction',
+ SYSTEM_INSTRUCTION_FALLBACK,
+ );
+ const prompt = buildEnrichmentPrompt(doc, researchPayload);
+ const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
+ const cleaned = stripCodeFences(rawResponse);
+ enrichedScript = JSON.parse(cleaned) as EnrichedScript;
+ console.log(`[check-research] Enriched script generated: "${enrichedScript.title}"`);
+ } catch (err) {
+ console.error('[check-research] Failed to generate enriched script:', err);
+ }
+
+ if (enrichedScript) {
+ // Run critic pass
+ const criticResult = await claudeCritic(enrichedScript);
+ const criticScore = criticResult.score;
+ console.log(`[check-research] Critic score: ${criticScore}/100 — ${criticResult.summary}`);
+
+ const qualityThreshold = await getConfigValue('pipeline_config', 'qualityThreshold', 50);
+ const isFlagged = criticScore < qualityThreshold;
+
+ await sanity
+ .patch(doc._id)
+ .set({
+ script: {
+ ...enrichedScript.script,
+ scenes: enrichedScript.script.scenes.map((scene, i) => ({
+ ...scene,
+ _key: `scene-${i + 1}`,
+ })),
+ },
+ scriptQualityScore: criticScore,
+ status: isFlagged ? 'flagged' : 'script_ready',
+ researchData: JSON.stringify(researchPayload),
+ ...(isFlagged && {
+ flaggedReason: `Quality score ${criticScore}/100. Issues: ${(criticResult.issues ?? []).join('; ') || 'Low quality score'}`,
+ }),
+ })
+ .commit();
+
+ console.log(`[check-research] "${doc.title}" → ${isFlagged ? 'flagged' : 'script_ready'} (score: ${criticScore})`);
+ return {
+ id: doc._id,
+ title: doc.title,
+ step: 'enriching',
+ outcome: isFlagged ? 'flagged' : 'script_ready',
+ };
+ }
+
+ // Fallback: no enriched script — transition with existing script
+ console.warn(`[check-research] No enriched script — transitioning "${doc.title}" to script_ready with existing script`);
+ await sanity
+ .patch(doc._id)
+ .set({
+ status: 'script_ready',
+ researchData: JSON.stringify(researchPayload),
+ })
+ .commit();
+
+ return { id: doc._id, title: doc.title, step: 'enriching', outcome: 'script_ready_fallback' };
+}
+
+// ---------------------------------------------------------------------------
+// Gemini Script Enrichment
+// ---------------------------------------------------------------------------
+
+// SYSTEM_INSTRUCTION fallback — used when content_config singleton doesn't exist yet in Sanity.
+// The live value is fetched from getConfigValue() inside stepEnriching().
+const SYSTEM_INSTRUCTION_FALLBACK = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
+
+Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles:
+- Start with a BOLD claim or surprising fact that makes people stop scrolling
+- Use analogies and real-world comparisons to explain technical concepts
+- Build tension: "Here's the problem... here's why it matters... here's the breakthrough"
+- Keep energy HIGH — short sentences, active voice, conversational tone
+- End with a clear takeaway that makes the viewer feel smarter
+- Target audience: developers who want to stay current but don't have time to read everything
+
+Script format: 60-90 second explainer videos. Think TikTok/YouTube Shorts energy with real educational depth.
+
+CodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.`;
+
+function buildEnrichmentPrompt(
+ doc: PipelineDoc,
+ research: ResearchPayload,
+): string {
+ const existingScript = doc.script
+ ? JSON.stringify(doc.script, null, 2)
+ : 'No existing script';
+
+ let researchContext = '';
+ researchContext += `### Briefing\n${research.briefing}\n\n`;
+
+ if (research.talkingPoints.length > 0) {
+ researchContext += `### Key Talking Points\n${research.talkingPoints.map((tp, i) => `${i + 1}. ${tp}`).join('\n')}\n\n`;
+ }
+
+ if (research.codeExamples.length > 0) {
+ researchContext += `### Code Examples (use these in "code" scenes)\n`;
+ for (const ex of research.codeExamples.slice(0, 5)) {
+ researchContext += `\`\`\`${ex.language}\n${ex.snippet}\n\`\`\`\nContext: ${ex.context}\n\n`;
+ }
+ }
+
+ if (research.comparisonData && research.comparisonData.length > 0) {
+ researchContext += `### Comparison Data (use in "comparison" scenes)\n`;
+ for (const comp of research.comparisonData) {
+ researchContext += `${comp.leftLabel} vs ${comp.rightLabel}:\n`;
+ for (const row of comp.rows) {
+ researchContext += ` - ${row.left} | ${row.right}\n`;
+ }
+ researchContext += '\n';
+ }
+ }
+
+ if (research.sceneHints.length > 0) {
+ researchContext += `### Scene Type Suggestions\n`;
+ for (const hint of research.sceneHints) {
+ researchContext += `- ${hint.suggestedSceneType}: ${hint.reason}\n`;
+ }
+ }
+
+ if (research.infographicUrls && research.infographicUrls.length > 0) {
+ researchContext += `\n### Infographics Available (${research.infographicUrls.length})\nMultiple infographics have been generated for this topic. Use sceneType "narration" with bRollUrl pointing to an infographic for visual scenes.\n`;
+ }
+
+ return `You have an existing video script for "${doc.title}" and new deep research data.
+Re-write the script to be MORE accurate, MORE insightful, and MORE engaging using the research.
+
+## Existing Script
+${existingScript}
+
+## Research Data (use this to create an informed, accurate script)
+${researchContext}
+
+Re-generate the complete video script as JSON. Keep the same format but enrich it with research insights.
+
+## Scene Types
+Each scene MUST have a "sceneType" that determines its visual treatment:
+- **"code"** — code snippets, API usage, config files. Provide actual code in the "code" field.
+- **"list"** — enumerated content: "Top 5 features", key takeaways. Provide items in the "list" field.
+- **"comparison"** — A-vs-B content. Provide structured data in the "comparison" field.
+- **"mockup"** — UI, website, app screen, or terminal output. Provide device type and content in the "mockup" field.
+- **"narration"** — conceptual explanations, introductions, or transitions. Default/fallback.
+
+## JSON Schema
+Return ONLY a JSON object:
+{
+ "title": "string - catchy video title",
+ "summary": "string - 1-2 sentence summary",
+ "sourceUrl": "string - URL of the primary source",
+ "topics": ["string array of relevant tags"],
+ "script": {
+ "hook": "string - attention-grabbing opening line (5-10 seconds)",
+ "scenes": [
+ {
+ "sceneNumber": 1,
+ "sceneType": "code | list | comparison | mockup | narration",
+ "narration": "string - what the narrator says",
+ "visualDescription": "string - what to show on screen",
+ "bRollKeywords": ["keyword1", "keyword2"],
+ "durationEstimate": 15,
+ "code": { "snippet": "string", "language": "string", "highlightLines": [1, 3] },
+ "list": { "items": ["Item 1", "Item 2"], "icon": "🚀" },
+ "comparison": { "leftLabel": "A", "rightLabel": "B", "rows": [{ "left": "...", "right": "..." }] },
+ "mockup": { "deviceType": "browser | phone | terminal", "screenContent": "..." },
+ "imagePrompts": ["Infographic 2D architecture diagram, black (#000000) background. Labeled diagram showing [Component A] \u2192 [Component B] \u2192 [Component C] with data flow arrows. Each component is a labeled box filled with purple (#7c3aed). White arrows connecting components, white text labels on every element. NO abstract art, NO geometric shapes, NO glowing orbs."]
+ }
+ ],
+ "cta": "string - call to action"
+ },
+ "qualityScore": 75
+}
+
+Requirements:
+- 3-5 scenes totaling 60-90 seconds
+- Use at least 2 different scene types
+- Each scene MUST include 2-5 imagePrompts. Every prompt MUST describe a LABELED ARCHITECTURAL DIAGRAM with named components, arrows, and text annotations.
+- Follow this structure for every imagePrompt: "Infographic 2D architecture diagram, black (#000000) background. Labeled diagram showing [specific named components from the narration]: [Component A] \u2192 [Component B] \u2192 [Component C]. Each component is a labeled box/node filled with purple (#7c3aed). White directional arrows showing data/control flow between components. White text labels on every element. NO abstract art, NO geometric shapes, NO glowing orbs, NO artistic metaphors."
+- BANNED in imagePrompts: spheres, orbs, waves, particles, abstract shapes, geometric patterns, glowing effects without labels, artistic metaphors. Every visual element MUST have a text label identifying what it represents.
+- Each imagePrompt must reference SPECIFIC technologies, APIs, functions, or concepts mentioned in that scene's narration. Generic visuals are rejected.
+- The FIRST scene's imagePrompts should be a striking architectural overview diagram of the main topic — labeled components showing the system/concept at a high level. This is the thumbnail/hook frame.
+- Do NOT include any script text, titles, or word overlays in the video. The narration audio carries all words.
+- Think of each imagePrompt as a technical diagram frame shown for 3-5 seconds while narration plays
+- Include REAL code snippets from the research where applicable
+- The qualityScore should be your honest self-assessment (0-100)
+- Return ONLY the JSON object, no markdown or extra text`;
+}
+
+// ---------------------------------------------------------------------------
+// Claude Critic (optional — degrades gracefully without ANTHROPIC_API_KEY)
+// ---------------------------------------------------------------------------
+
+async function claudeCritic(script: EnrichedScript): Promise {
+ const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
+ if (!ANTHROPIC_API_KEY) {
+ return { score: script.qualityScore, issues: [], summary: 'No critic available' };
+ }
+
+ try {
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-api-key': ANTHROPIC_API_KEY,
+ 'anthropic-version': '2023-06-01',
+ },
+ body: JSON.stringify({
+ model: 'claude-sonnet-4-20250514',
+ max_tokens: 1024,
+ messages: [
+ {
+ role: 'user',
+ content: `You are a quality reviewer for short-form educational video scripts about web development.
+
+Review this video script and provide a JSON response with:
+- "score": number 0-100 (overall quality rating)
+- "issues": string[] (list of specific problems, if any)
+- "summary": string (brief overall assessment)
+
+Evaluate based on:
+1. Educational value — does it teach something useful?
+2. Engagement — is the hook compelling? Is the pacing good?
+3. Accuracy — are there any technical inaccuracies?
+4. Clarity — is the narration clear and concise?
+5. Visual direction — are the visual descriptions actionable?
+
+Script to review:
+${JSON.stringify(script, null, 2)}
+
+Respond with ONLY the JSON object.`,
+ },
+ ],
+ }),
+ });
+
+ if (!res.ok) {
+ console.warn(`[check-research] Claude critic failed: ${res.status}`);
+ return { score: script.qualityScore, issues: [], summary: 'Critic API error' };
+ }
+
+ const data = (await res.json()) as { content?: Array<{ text?: string }> };
+ const text = data.content?.[0]?.text ?? '{}';
+
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ return { score: script.qualityScore, issues: [], summary: 'Could not parse critic response' };
+ }
+
+ const parsed = JSON.parse(jsonMatch[0]) as CriticResult;
+ return {
+ score: typeof parsed.score === 'number' ? parsed.score : script.qualityScore,
+ issues: Array.isArray(parsed.issues) ? parsed.issues : [],
+ summary: typeof parsed.summary === 'string' ? parsed.summary : 'No summary',
+ };
+ } catch (err) {
+ console.warn('[check-research] Claude critic error:', err);
+ return { score: script.qualityScore, issues: [], summary: 'Critic error' };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Research payload builder
+// ---------------------------------------------------------------------------
+
+function extractTalkingPoints(text: string): string[] {
+ const lines = text.split('\n');
+ const points: string[] = [];
+ for (const line of lines) {
+ const cleaned = line.replace(/^[\s]*[-•*\d]+[.)]\s*/, '').trim();
+ if (cleaned.length > 20) {
+ points.push(cleaned);
+ }
+ }
+ return points.slice(0, 8);
+}
+
+function extractCodeExamples(text: string): Array<{ snippet: string; language: string; context: string }> {
+ const examples: Array<{ snippet: string; language: string; context: string }> = [];
+ const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
+ let match: RegExpExecArray | null;
+
+ while ((match = codeBlockRegex.exec(text)) !== null) {
+ const language = match[1] || 'typescript';
+ const snippet = match[2].trim();
+ const beforeBlock = text.slice(0, match.index);
+ const contextLines = beforeBlock.split('\n').filter((l) => l.trim());
+ const context =
+ contextLines.length > 0
+ ? contextLines[contextLines.length - 1].trim()
+ : 'Code example';
+ examples.push({ snippet, language, context });
+ }
+
+ return examples;
+}
+
+function classifyScene(
+ content: string,
+): 'narration' | 'code' | 'list' | 'comparison' | 'mockup' {
+ if (
+ /```[\s\S]*?```/.test(content) ||
+ /^\s{2,}(const|let|var|function|import|export|class|def|return)\b/m.test(content)
+ ) {
+ return 'code';
+ }
+ const listMatches = content.match(/^[\s]*[-•*\d]+[.)]\s/gm);
+ if (listMatches && listMatches.length >= 3) {
+ return 'list';
+ }
+ if (
+ /\bvs\.?\b/i.test(content) ||
+ /\bcompare[ds]?\b/i.test(content) ||
+ /\bdifference[s]?\b/i.test(content) ||
+ /\bpros\s+(and|&)\s+cons\b/i.test(content)
+ ) {
+ return 'comparison';
+ }
+ if (
+ /\b(UI|interface|dashboard|screen|layout|component|widget|button|modal)\b/i.test(content)
+ ) {
+ return 'mockup';
+ }
+ return 'narration';
+}
+
+function classifySourceType(url: string): 'youtube' | 'article' | 'docs' | 'unknown' {
+ if (!url) return 'unknown';
+ const lower = url.toLowerCase();
+ if (lower.includes('youtube.com') || lower.includes('youtu.be')) return 'youtube';
+ if (lower.includes('/docs') || lower.includes('documentation') || lower.includes('developer.') || lower.includes('mdn')) return 'docs';
+ if (lower.includes('blog') || lower.includes('medium.com') || lower.includes('dev.to') || lower.includes('hashnode')) return 'article';
+ return 'unknown';
+}
+
+function buildResearchPayload(
+ doc: PipelineDoc,
+ researchData: Record,
+): ResearchPayload {
+ // If researchData already has the full ResearchPayload shape, use it directly
+ if (researchData.topic && researchData.talkingPoints && researchData.sceneHints) {
+ return researchData as unknown as ResearchPayload;
+ }
+
+ // Legacy format: extract from briefing + sources
+ const briefing = (researchData.briefing as string) ?? '';
+ const sources = (researchData.sources as Array<{ url: string; title: string }>) ?? [];
+ const infographicUrls = (researchData.infographicUrls as string[]) ?? [];
+
+ const talkingPoints = extractTalkingPoints(briefing);
+ const codeExamples = extractCodeExamples(briefing);
+
+ const sections = briefing
+ .split(/\n(?=#{1,3}\s)|\n\n/)
+ .filter((s) => s.trim().length > 50);
+ const sceneHints = sections.map((section) => ({
+ content: section.slice(0, 500),
+ suggestedSceneType: classifyScene(section),
+ reason: 'Classified from research content',
+ }));
+
+ return {
+ topic: doc.title,
+ notebookId: doc.researchNotebookId || '',
+ createdAt: doc._updatedAt,
+ completedAt: new Date().toISOString(),
+ sources: sources.map((s) => ({
+ title: s.title,
+ url: s.url,
+ type: classifySourceType(s.url),
+ })),
+ briefing,
+ talkingPoints,
+ codeExamples,
+ sceneHints,
+ infographicUrls: infographicUrls.length > 0 ? infographicUrls : undefined,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Route Handler
+// ---------------------------------------------------------------------------
+
+export async function GET(request: NextRequest) {
+ // Auth: fail-closed — if CRON_SECRET is not set, reject
+ const cronSecret = process.env.CRON_SECRET;
+ if (!cronSecret) {
+ console.error('[check-research] CRON_SECRET not configured');
+ return Response.json({ error: 'Server misconfigured' }, { status: 503 });
+ }
+ const authHeader = request.headers.get('authorization');
+ if (authHeader !== `Bearer ${cronSecret}`) {
+ console.error('[check-research] Unauthorized request');
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const sanity = getSanityWriteClient();
+
+ // Single query for all active pipeline statuses
+ // Docs may reach research_complete without researchInteractionId when deep research
+ // is disabled or fails — the ID filter only applies to "researching" status
+ const docs = await sanity.fetch(
+ `*[_type == "automatedVideo" && status in ["researching", "research_complete", "infographics_generating", "enriching"] && (status != "researching" || defined(researchInteractionId) || defined(researchNotebookId))] {
+ _id, title, status, researchInteractionId, researchNotebookId, trendScore, trendSources,
+ script, researchData, infographicProgress, _updatedAt
+ }`,
+ );
+
+ console.log(`[check-research] Found ${docs.length} docs in pipeline`);
+
+ if (docs.length === 0) {
+ return Response.json({ success: true, message: 'No docs to process', results: [] });
+ }
+
+ const results: StepResult[] = [];
+
+ // Phase 1: Stuck detection — runs FIRST, no external API calls
+ const stuckThresholds = await buildStuckThresholds();
+ const stuckResults = await flagStuckDocs(docs, sanity, stuckThresholds);
+ results.push(...stuckResults);
+
+ // Remove flagged docs from further processing
+ const stuckIds = new Set(stuckResults.map((r) => r.id));
+ const activeDocs = docs.filter((d) => !stuckIds.has(d._id));
+
+ // Group by status
+ const researching = activeDocs.filter((d) => d.status === 'researching');
+ const researchComplete = activeDocs.filter((d) => d.status === 'research_complete');
+ const infographicsGenerating = activeDocs.filter((d) => d.status === 'infographics_generating');
+ const enriching = activeDocs.filter((d) => d.status === 'enriching');
+
+ console.log(
+ `[check-research] Pipeline: ${researching.length} researching, ${researchComplete.length} research_complete, ${infographicsGenerating.length} infographics_generating, ${enriching.length} enriching`,
+ );
+
+ // Check enableDeepResearch toggle
+ const enableDeepResearch = await getConfigValue('pipeline_config', 'enableDeepResearch', false);
+
+ // Step 1: researching → research_complete
+ if (!enableDeepResearch) {
+ // Deep research disabled — skip researching docs to enriching
+ for (const doc of researching.slice(0, MAX_DOCS_PER_STATUS)) {
+ try {
+ await sanity.patch(doc._id).set({ status: doc.script ? 'script_ready' : 'enriching' }).commit();
+ results.push({ id: doc._id, title: doc.title, step: 'researching', outcome: 'deep_research_disabled_skip' });
+ } catch (err) {
+ console.error(`[check-research] Error skipping researching doc ${doc._id}:`, err);
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ step: 'researching',
+ outcome: 'error',
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ } else {
+ for (const doc of researching.slice(0, MAX_DOCS_PER_STATUS)) {
+ try {
+ const result = await stepResearching(doc, sanity);
+ results.push(result);
+ } catch (err) {
+ console.error(`[check-research] Error in stepResearching for ${doc._id}:`, err);
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ step: 'researching',
+ outcome: 'error',
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ }
+
+ // Step 2: research_complete → enriching (infographics generated inline)
+ for (const doc of researchComplete.slice(0, MAX_DOCS_PER_STATUS)) {
+ try {
+ const result = await stepResearchComplete(doc, sanity);
+ results.push(result);
+ } catch (err) {
+ console.error(`[check-research] Error in stepResearchComplete for ${doc._id}:`, err);
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ step: 'research_complete',
+ outcome: 'error',
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ // Step 3: infographics_generating → enriching (batched infographic generation)
+ for (const doc of infographicsGenerating.slice(0, MAX_DOCS_PER_STATUS)) {
+ try {
+ const result = await stepInfographicsGenerating(doc, sanity);
+ results.push(result);
+ } catch (err) {
+ console.error(`[check-research] Error in stepInfographicsGenerating for ${doc._id}:`, err);
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ step: 'infographics_generating',
+ outcome: 'error',
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ // Step 4: enriching → script_ready
+ for (const doc of enriching.slice(0, MAX_DOCS_PER_STATUS)) {
+ try {
+ const result = await stepEnriching(doc, sanity);
+ results.push(result);
+ } catch (err) {
+ console.error(`[check-research] Error in stepEnriching for ${doc._id}:`, err);
+ results.push({
+ id: doc._id,
+ title: doc.title,
+ step: 'enriching',
+ outcome: 'error',
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ console.log(`[check-research] Run complete: ${results.length} results`);
+ return Response.json({ success: true, results });
+ } catch (err) {
+ console.error('[check-research] Unexpected error:', err);
+ return Response.json(
+ {
+ success: false,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/cron/ingest/route.ts b/apps/web/app/api/cron/ingest/route.ts
new file mode 100644
index 000000000..96076485c
--- /dev/null
+++ b/apps/web/app/api/cron/ingest/route.ts
@@ -0,0 +1,726 @@
+import type { NextRequest } from "next/server";
+
+import { generateWithGemini, stripCodeFences } from "@/lib/gemini";
+import { writeClient } from "@/lib/sanity-write-client";
+import { getConfigValue } from "@/lib/config";
+import { discoverTrends, type TrendResult } from "@/lib/services/trend-discovery";
+import type { ResearchPayload } from "@/lib/services/research";
+import { submitResearch } from "@/lib/services/gemini-research";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface ScriptScene {
+ sceneNumber: number;
+ sceneType: "narration" | "code" | "list" | "comparison" | "mockup";
+ narration: string;
+ visualDescription: string;
+ bRollKeywords: string[];
+ durationEstimate: number;
+ imagePrompts?: string[];
+ // Scene-type-specific data
+ code?: {
+ snippet: string;
+ language: string;
+ highlightLines?: number[];
+ };
+ list?: {
+ items: string[];
+ icon?: string;
+ };
+ comparison?: {
+ leftLabel: string;
+ rightLabel: string;
+ rows: { left: string; right: string }[];
+ };
+ mockup?: {
+ deviceType: "browser" | "phone" | "terminal";
+ screenContent: string;
+ };
+}
+
+interface GeneratedScript {
+ title: string;
+ summary: string;
+ sourceUrl: string;
+ topics: string[];
+ script: {
+ hook: string;
+ scenes: ScriptScene[];
+ cta: string;
+ };
+ qualityScore: number;
+}
+
+interface CriticResult {
+ score: number;
+ issues: string[];
+ summary: string;
+}
+
+// ---------------------------------------------------------------------------
+// Fallback topics (used when discoverTrends returns empty)
+// ---------------------------------------------------------------------------
+
+const FALLBACK_TRENDS: TrendResult[] = [
+ {
+ topic: "React Server Components: The Future of Web Development",
+ slug: "react-server-components",
+ score: 80,
+ signals: [{ source: "blog", title: "React Server Components", url: "https://react.dev/blog", score: 80 }],
+ whyTrending: "Major shift in React architecture",
+ suggestedAngle: "Explain what RSC changes for everyday React developers",
+ },
+ {
+ topic: "TypeScript 5.x: New Features Every Developer Should Know",
+ slug: "typescript-5x-features",
+ score: 75,
+ signals: [{ source: "blog", title: "TypeScript 5.x", url: "https://devblogs.microsoft.com/typescript/", score: 75 }],
+ whyTrending: "New TypeScript release with major DX improvements",
+ suggestedAngle: "Walk through the top 5 new features with code examples",
+ },
+ {
+ topic: "Next.js App Router Best Practices for 2025",
+ slug: "nextjs-app-router-2025",
+ score: 70,
+ signals: [{ source: "blog", title: "Next.js App Router", url: "https://nextjs.org/blog", score: 70 }],
+ whyTrending: "App Router adoption is accelerating",
+ suggestedAngle: "Common pitfalls and how to avoid them",
+ },
+ {
+ topic: "The State of CSS in 2025: Container Queries, Layers, and More",
+ slug: "css-2025-state",
+ score: 65,
+ signals: [{ source: "blog", title: "CSS 2025", url: "https://web.dev/blog", score: 65 }],
+ whyTrending: "CSS has gained powerful new features",
+ suggestedAngle: "Demo the top 3 CSS features you should be using today",
+ },
+ {
+ topic: "WebAssembly is Changing How We Build Web Apps",
+ slug: "webassembly-web-apps",
+ score: 60,
+ signals: [{ source: "blog", title: "WebAssembly", url: "https://webassembly.org/", score: 60 }],
+ whyTrending: "WASM adoption growing in production apps",
+ suggestedAngle: "Real-world use cases where WASM outperforms JS",
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Common stop words stripped when extracting search terms for dedup
+// ---------------------------------------------------------------------------
+
+const STOP_WORDS = new Set([
+ "the", "a", "an", "is", "of", "in", "for", "and", "to", "how", "why",
+ "what", "with", "new", "your", "are", "was", "be", "been", "being",
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
+ "should", "may", "might", "must", "shall", "can", "need", "dare",
+ "ought", "used", "every", "all", "both", "few", "more", "most",
+ "other", "some", "such", "no", "nor", "not", "only", "own", "same",
+ "so", "than", "too", "very", "just", "because", "as", "until",
+ "while", "about", "between", "through", "during", "before", "after",
+ "above", "below", "from", "up", "down", "out", "on", "off", "over",
+ "under", "again", "further", "then", "once", "that", "this", "these",
+ "those", "it", "its", "we", "you", "they", "them", "their", "our",
+ "my", "he", "she", "him", "her", "his", "who", "which", "when",
+ "where", "there", "here",
+]);
+
+// ---------------------------------------------------------------------------
+// Topic Dedup — check if a topic has already been covered recently
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract 2-3 meaningful search terms from a topic title.
+ * Strips stop words and short tokens, returns lowercase terms.
+ */
+function extractSearchTerms(title: string): string[] {
+ const words = title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s.-]/g, " ")
+ .split(/\s+/)
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w));
+
+ // Return up to 3 most meaningful terms (first terms tend to be most specific)
+ return words.slice(0, 3);
+}
+
+/**
+ * Check whether a topic (by title + slug) has already been covered within
+ * the configured dedup window. Queries both `contentIdea` and `automatedVideo`
+ * documents in Sanity.
+ *
+ * Returns `true` if the topic should be skipped (already covered).
+ */
+async function isTopicAlreadyCovered(topic: string, topics: string[]): Promise {
+ const dedupWindowDays = await getConfigValue("content_config", "dedupWindowDays", 90);
+
+ // Dedup disabled when window is 0
+ if (dedupWindowDays <= 0) {
+ return false;
+ }
+
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - dedupWindowDays);
+ const cutoffISO = cutoff.toISOString();
+
+ const searchTerms = extractSearchTerms(topic);
+ if (searchTerms.length === 0) {
+ return false;
+ }
+
+ // Build a GROQ match pattern — each term becomes a wildcard prefix match
+ // GROQ `match` supports patterns like "react*" and works with ||
+ const matchPatterns = searchTerms.map((t) => `${t}*`);
+
+ // Query 1: Title-based match on contentIdea and automatedVideo
+ // The `match` operator in GROQ does case-insensitive prefix matching
+ const titleQuery = `{
+ "ideas": *[_type == "contentIdea" && _createdAt > $cutoff && title match $patterns] { _id, title, topics },
+ "videos": *[_type == "automatedVideo" && _createdAt > $cutoff && title match $patterns] { _id, title }
+ }`;
+
+ try {
+ const titleResults = await writeClient.fetch(titleQuery, {
+ cutoff: cutoffISO,
+ patterns: matchPatterns,
+ });
+
+ const ideaMatches: Array<{ _id: string; title: string; topics?: string[] }> = titleResults.ideas ?? [];
+ const videoMatches: Array<{ _id: string; title: string }> = titleResults.videos ?? [];
+
+ // If any title match is found, topic is covered
+ if (ideaMatches.length > 0 || videoMatches.length > 0) {
+ console.log(
+ `[CRON/ingest] Dedup: title match found for "${topic}" — ${ideaMatches.length} ideas, ${videoMatches.length} videos`,
+ );
+ return true;
+ }
+
+ // Query 2: Check topic tag overlap on contentIdea documents
+ // We consider a topic covered if 2+ tags overlap
+ if (topics.length > 0) {
+ const topicLower = topics.map((t) => t.toLowerCase());
+ const overlapQuery = `*[_type == "contentIdea" && _createdAt > $cutoff && count((topics[])[@ in $topicTags]) >= 2] { _id, title, topics }`;
+
+ const overlapResults = await writeClient.fetch(overlapQuery, {
+ cutoff: cutoffISO,
+ topicTags: topicLower,
+ });
+
+ if (overlapResults.length > 0) {
+ console.log(
+ `[CRON/ingest] Dedup: topic overlap found for "${topic}" — ${overlapResults.length} matching ideas`,
+ );
+ return true;
+ }
+ }
+
+ return false;
+ } catch (err) {
+ // If dedup query fails, don't block the pipeline — log and continue
+ console.warn("[CRON/ingest] Dedup query failed, allowing topic:", err);
+ return false;
+ }
+}
+
+/**
+ * Check if a slug already exists on an automatedVideo document.
+ */
+async function isSlugTaken(slug: string): Promise {
+ try {
+ const results = await writeClient.fetch(
+ `*[_type == "automatedVideo" && slug.current == $slug][0..0] { _id }`,
+ { slug },
+ );
+ return results.length > 0;
+ } catch (err) {
+ console.warn("[CRON/ingest] Slug check failed, allowing:", err);
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Gemini Script Generation
+// ---------------------------------------------------------------------------
+
+// SYSTEM_INSTRUCTION fallback — used when content_config singleton doesn't exist yet in Sanity.
+// The live value is fetched from getConfigValue() inside the GET handler.
+const SYSTEM_INSTRUCTION_FALLBACK = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
+
+Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles:
+- Start with a BOLD claim or surprising fact that makes people stop scrolling
+- Use analogies and real-world comparisons to explain technical concepts
+- Build tension: "Here's the problem... here's why it matters... here's the breakthrough"
+- Keep energy HIGH — short sentences, active voice, conversational tone
+- End with a clear takeaway that makes the viewer feel smarter
+- Target audience: developers who want to stay current but don't have time to read everything
+
+Script format: 2-4 minute explainer videos for horizontal YouTube, 60-90 seconds for Shorts. Think Cleo Abram energy with real educational depth.
+
+CodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.`;
+
+function buildPrompt(trends: TrendResult[], research?: ResearchPayload): string {
+ const topicList = trends
+ .map((t, i) => `${i + 1}. "${t.topic}" (score: ${t.score}) — ${t.whyTrending}\n Sources: ${t.signals.map(s => s.url).join(", ")}`)
+ .join("\n");
+
+ // If we have research data, include it as enrichment
+ let researchContext = "";
+ if (research) {
+ researchContext = `\n\n## Research Data (use this to create an informed, accurate script)\n\n`;
+ researchContext += `### Briefing\n${research.briefing}\n\n`;
+
+ if (research.talkingPoints.length > 0) {
+ researchContext += `### Key Talking Points\n${research.talkingPoints.map((tp, i) => `${i + 1}. ${tp}`).join("\n")}\n\n`;
+ }
+
+ if (research.codeExamples.length > 0) {
+ researchContext += `### Code Examples (use these in "code" scenes)\n`;
+ for (const ex of research.codeExamples.slice(0, 5)) {
+ researchContext += `\`\`\`${ex.language}\n${ex.snippet}\n\`\`\`\nContext: ${ex.context}\n\n`;
+ }
+ }
+
+ if (research.comparisonData && research.comparisonData.length > 0) {
+ researchContext += `### Comparison Data (use in "comparison" scenes)\n`;
+ for (const comp of research.comparisonData) {
+ researchContext += `${comp.leftLabel} vs ${comp.rightLabel}:\n`;
+ for (const row of comp.rows) {
+ researchContext += ` - ${row.left} | ${row.right}\n`;
+ }
+ researchContext += "\n";
+ }
+ }
+
+ if (research.sceneHints.length > 0) {
+ researchContext += `### Scene Type Suggestions\n`;
+ for (const hint of research.sceneHints) {
+ researchContext += `- ${hint.suggestedSceneType}: ${hint.reason}\n`;
+ }
+ }
+
+ if (research.infographicUrls && research.infographicUrls.length > 0) {
+ researchContext += `\n### Infographics Available (${research.infographicUrls.length})\nMultiple infographics have been generated for this topic. Use sceneType "narration" with bRollUrl pointing to an infographic for visual scenes.\n`;
+ }
+ }
+
+ return `Here are today's trending web development topics:
+
+${topicList}${researchContext}
+
+Pick the MOST interesting and timely topic for an explainer video (2-4 minutes for horizontal YouTube). Then generate a complete video script as JSON.
+
+## Scene Types
+
+Each scene MUST have a "sceneType" that determines its visual treatment. Choose the best type for the content:
+
+- **"code"** — Use when explaining code snippets, API usage, config files, or CLI commands. Provide the actual code in the "code" field.
+- **"list"** — Use for enumerated content: "Top 5 features", "3 reasons why", key takeaways. Provide items in the "list" field.
+- **"comparison"** — Use for A-vs-B content: "React vs Vue", "SQL vs NoSQL", pros/cons. Provide structured data in the "comparison" field.
+- **"mockup"** — Use when showing a UI, website, app screen, or terminal output. Provide device type and content description in the "mockup" field.
+- **"narration"** — Use for conceptual explanations, introductions, or transitions where B-roll footage is appropriate. This is the default/fallback.
+
+**Guidelines:**
+- A good video uses 2-3 different scene types for visual variety
+- Code-heavy topics should have at least one "code" scene
+- Always include "bRollKeywords" and "visualDescription" as fallbacks even for non-narration scenes
+- For "code" scenes, provide REAL, working code snippets (not pseudocode)
+- For "list" scenes, provide 3-6 concise items
+- For "comparison" scenes, provide 2-4 rows
+
+## Infographic Image Prompts
+
+CRITICAL: This video will be a visual infographic explainer. There will be NO text, titles, or script words shown on screen — the narration audio carries all words. The visuals are entirely infographic images.
+
+For EACH scene, generate an "imagePrompts" array with 2-5 image generation prompts. Each prompt should follow this exact template:
+
+"Infographic 2D architecture diagram, black (#000000) background. Labeled diagram showing [SPECIFIC NAMED COMPONENTS FROM THIS SCENE]: [Component A] → [Component B] → [Component C] with data flow arrows. Each component is a labeled box filled with purple (#7c3aed). White arrows connecting components, white text labels on every element. NO abstract art, NO geometric shapes, NO glowing orbs."
+
+Replace [SPECIFIC VISUAL FOR THIS SCENE] with a detailed description of what the infographic should show for that particular scene. Be specific — reference the actual technical concepts, comparisons, or workflows being discussed.
+
+Guidelines for image prompts:
+- Each scene needs Math.ceil(durationEstimate / 4) prompts (one image every ~4 seconds)
+- A 15-second scene needs 4 prompts, a 20-second scene needs 5
+- Each prompt should show a DIFFERENT aspect or angle of the scene's content
+- For code scenes: show labeled architecture diagrams with named components, data flow arrows, and text annotations (NOT the code itself, NOT abstract art)
+- For comparison scenes: show labeled side-by-side comparison diagrams with named components and feature annotations
+- For list scenes: show each item as a labeled box/node in a diagram layout with connecting arrows
+- Make prompts visually varied — don't repeat the same layout
+- STRICT color palette: pure black background (#000000), vivid purple (#7c3aed) for highlighted elements, white for lines, arrows, and text annotations. Do NOT use blue, green, orange, red, or gradient backgrounds
+- BANNED in imagePrompts: spheres, orbs, waves, particles, abstract shapes, geometric patterns, glowing effects without labels, artistic metaphors. Every visual element MUST have a text label identifying what it represents
+- Each imagePrompt must reference SPECIFIC technologies, APIs, functions, or concepts mentioned in that scene's narration. Generic visuals are rejected
+- FIRST SCENE imagePrompts should be a striking architectural overview diagram of the main topic — labeled components showing the system/concept at a high level. This is the thumbnail/hook frame
+
+## JSON Schema
+
+Return ONLY a JSON object matching this exact schema:
+
+{
+ "title": "string - catchy video title",
+ "summary": "string - 1-2 sentence summary of what the video covers",
+ "sourceUrl": "string - URL of the source article/trend you picked",
+ "topics": ["string array of relevant tags, e.g. react, nextjs, typescript"],
+ "script": {
+ "hook": "string - attention-grabbing opening line (5-10 seconds)",
+ "scenes": [
+ {
+ "sceneNumber": 1,
+ "sceneType": "code | list | comparison | mockup | narration",
+ "narration": "string - what the narrator says",
+ "visualDescription": "string - what to show on screen (fallback for all types)",
+ "bRollKeywords": ["keyword1", "keyword2"],
+ "durationEstimate": 15,
+ "imagePrompts": ["Infographic 2D architecture diagram, black (#000000) background. Labeled diagram showing [Component A] → [Component B] → [Component C] with data flow arrows. Each component is a labeled box filled with purple (#7c3aed). White arrows and white text labels. NO abstract art, NO glowing orbs."],
+ "code": {
+ "snippet": "string - actual code to display (only for sceneType: code)",
+ "language": "typescript | javascript | jsx | tsx | css | html | json | bash",
+ "highlightLines": [1, 3]
+ },
+ "list": {
+ "items": ["Item 1", "Item 2", "Item 3"],
+ "icon": "🚀"
+ },
+ "comparison": {
+ "leftLabel": "Option A",
+ "rightLabel": "Option B",
+ "rows": [
+ { "left": "Feature of A", "right": "Feature of B" }
+ ]
+ },
+ "mockup": {
+ "deviceType": "browser | phone | terminal",
+ "screenContent": "Description of what appears on the device screen"
+ }
+ }
+ ],
+ "cta": "string - call to action (subscribe, check link, etc.)"
+ },
+ "qualityScore": 75
+}
+
+Requirements:
+- The script should have 8-15 scenes totaling 2-4 minutes (120-240 seconds)
+- The hook should be punchy and curiosity-driven
+- Use at least 2 different scene types for visual variety
+- Only include the type-specific field that matches the sceneType (e.g., only include "code" when sceneType is "code")
+- For "code" scenes, provide real, syntactically correct code
+- The qualityScore should be your honest self-assessment (0-100)
+- Each scene MUST include an "imagePrompts" array with 2-5 image generation prompts. Every prompt MUST describe a LABELED ARCHITECTURAL DIAGRAM with named components, arrows, and text annotations
+- Follow this structure for every imagePrompt: "Infographic 2D architecture diagram, black (#000000) background. Labeled diagram showing [specific named components from the narration]: [Component A] → [Component B] → [Component C]. Each component is a labeled box/node filled with purple (#7c3aed). White directional arrows showing data/control flow. White text labels on every element. NO abstract art, NO geometric shapes, NO glowing orbs, NO artistic metaphors."
+- Do NOT include any text overlays, titles, or script words in the video — narration audio carries all words
+- Calculate prompt count per scene: Math.ceil(durationEstimate / 4)
+- Return ONLY the JSON object, no markdown or extra text`;
+}
+
+// ---------------------------------------------------------------------------
+// Optional Claude Critic
+// ---------------------------------------------------------------------------
+
+async function claudeCritic(script: GeneratedScript): Promise {
+ const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
+ if (!ANTHROPIC_API_KEY) {
+ return { score: script.qualityScore, issues: [], summary: "No critic available" };
+ }
+
+ try {
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": ANTHROPIC_API_KEY,
+ "anthropic-version": "2023-06-01",
+ },
+ body: JSON.stringify({
+ model: "claude-sonnet-4-20250514",
+ max_tokens: 1024,
+ messages: [
+ {
+ role: "user",
+ content: `You are a quality reviewer for short-form educational video scripts about web development.
+
+Review this video script and provide a JSON response with:
+- "score": number 0-100 (overall quality rating)
+- "issues": string[] (list of specific problems, if any)
+- "summary": string (brief overall assessment)
+
+Evaluate based on:
+1. Educational value — does it teach something useful?
+2. Engagement — is the hook compelling? Is the pacing good?
+3. Accuracy — are there any technical inaccuracies?
+4. Clarity — is the narration clear and concise?
+5. Visual direction — are the visual descriptions actionable?
+
+Script to review:
+${JSON.stringify(script, null, 2)}
+
+Respond with ONLY the JSON object.`,
+ },
+ ],
+ }),
+ });
+
+ if (!res.ok) {
+ console.warn(`[CRON/ingest] Claude critic failed: ${res.status}`);
+ return { score: script.qualityScore, issues: [], summary: "Critic API error" };
+ }
+
+ const data = await res.json();
+ const text = data.content?.[0]?.text ?? "{}";
+
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ return { score: script.qualityScore, issues: [], summary: "Could not parse critic response" };
+ }
+
+ const parsed = JSON.parse(jsonMatch[0]) as CriticResult;
+ return {
+ score: typeof parsed.score === "number" ? parsed.score : script.qualityScore,
+ issues: Array.isArray(parsed.issues) ? parsed.issues : [],
+ summary: typeof parsed.summary === "string" ? parsed.summary : "No summary",
+ };
+ } catch (err) {
+ console.warn("[CRON/ingest] Claude critic error:", err);
+ return { score: script.qualityScore, issues: [], summary: "Critic error" };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sanity Document Creation
+// ---------------------------------------------------------------------------
+
+async function createSanityDocuments(
+ script: GeneratedScript,
+ criticResult: CriticResult,
+ selectedTrend: TrendResult,
+ qualityThreshold: number,
+ research?: ResearchPayload,
+ researchInteractionId?: string,
+) {
+ const isFlagged = criticResult.score < qualityThreshold;
+ // When research is in-flight, status is "researching" (check-research cron will transition to script_ready)
+ const isResearching = !!researchInteractionId;
+ // Route through research_complete so check-research generates infographics before enrichment
+ const status = isFlagged ? "flagged" : isResearching ? "researching" : "research_complete";
+
+ const contentIdea = await writeClient.create({
+ _type: "contentIdea",
+ title: script.title,
+ summary: script.summary,
+ sourceUrl: script.sourceUrl,
+ topics: script.topics,
+ collectedAt: new Date().toISOString(),
+ status: "approved",
+ });
+
+ console.log(`[CRON/ingest] Created contentIdea: ${contentIdea._id}`);
+
+ const automatedVideo = await writeClient.create({
+ _type: "automatedVideo",
+ title: script.title,
+ slug: { _type: "slug", current: script.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") },
+ contentIdea: {
+ _type: "reference",
+ _ref: contentIdea._id,
+ },
+ script: {
+ ...script.script,
+ scenes: script.script.scenes.map((scene, i) => ({
+ ...scene,
+ _key: `scene-${i + 1}`,
+ })),
+ },
+ scriptQualityScore: criticResult.score,
+ status,
+ ...(isFlagged && {
+ flaggedReason: `Quality score ${criticResult.score}/100. Issues: ${criticResult.issues.join("; ") || "Low quality score"}`,
+ }),
+ trendScore: selectedTrend.score,
+ trendSources: selectedTrend.signals.map(s => s.source).join(", "),
+ researchInteractionId: researchInteractionId || undefined,
+ });
+
+ console.log(`[CRON/ingest] Created automatedVideo: ${automatedVideo._id}`);
+
+ return {
+ contentIdeaId: contentIdea._id,
+ automatedVideoId: automatedVideo._id,
+ status,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Route Handler
+// ---------------------------------------------------------------------------
+
+export async function GET(request: NextRequest) {
+ const cronSecret = process.env.CRON_SECRET;
+ if (!cronSecret) {
+ console.error("[CRON/ingest] CRON_SECRET not configured");
+ return Response.json({ error: "Server misconfigured" }, { status: 503 });
+ }
+ const authHeader = request.headers.get("authorization");
+ if (authHeader !== `Bearer ${cronSecret}`) {
+ console.error(
+ "[CRON/ingest] Unauthorized request: invalid authorization header",
+ );
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ // Fetch config values once per invocation (5-min in-memory cache)
+ const SYSTEM_INSTRUCTION = await getConfigValue(
+ "content_config",
+ "systemInstruction",
+ SYSTEM_INSTRUCTION_FALLBACK,
+ );
+ const enableDeepResearch = await getConfigValue(
+ "pipeline_config",
+ "enableDeepResearch",
+ false,
+ );
+ const qualityThreshold = await getConfigValue(
+ "pipeline_config",
+ "qualityThreshold",
+ 50,
+ );
+
+ // Step 1: Discover trending topics (replaces fetchTrendingTopics)
+ console.log("[CRON/ingest] Discovering trending topics...");
+ let trends: TrendResult[];
+ try {
+ trends = await discoverTrends({ lookbackDays: 7, maxTopics: 10 });
+ console.log(`[CRON/ingest] Found ${trends.length} trending topics`);
+ } catch (err) {
+ console.warn("[CRON/ingest] Trend discovery failed, using fallback topics:", err);
+ trends = [];
+ }
+
+ // Fall back to hardcoded topics if discovery returns empty or failed
+ if (trends.length === 0) {
+ console.warn("[CRON/ingest] No trends discovered, using fallback topics");
+ trends = FALLBACK_TRENDS;
+ }
+
+ // Step 1.5: Dedup — walk trends list, skip already-covered topics
+ console.log("[CRON/ingest] Dedup: checking trends for already-covered topics...");
+ let selectedTrend: TrendResult | undefined;
+ let skippedCount = 0;
+
+ for (const trend of trends) {
+ // Extract keyword-style topics from the trend title for tag overlap check
+ const topicKeywords = extractSearchTerms(trend.topic);
+
+ const covered = await isTopicAlreadyCovered(trend.topic, topicKeywords);
+ if (covered) {
+ console.log(`[CRON/ingest] Dedup: skipping "${trend.topic}" (score: ${trend.score}) — already covered`);
+ skippedCount++;
+ continue;
+ }
+
+ // Also check for slug collision
+ if (trend.slug) {
+ const slugTaken = await isSlugTaken(trend.slug);
+ if (slugTaken) {
+ console.log(`[CRON/ingest] Dedup: skipping "${trend.topic}" — slug "${trend.slug}" already exists`);
+ skippedCount++;
+ continue;
+ }
+ }
+
+ selectedTrend = trend;
+ break;
+ }
+
+ if (!selectedTrend) {
+ console.log(`[CRON/ingest] Dedup: all ${trends.length} trending topics already covered. Skipping ingestion.`);
+ return Response.json({
+ success: true,
+ skipped: true,
+ message: "All trending topics already covered",
+ trendCount: trends.length,
+ skippedCount,
+ });
+ }
+
+ console.log(`[CRON/ingest] Dedup: selected "${selectedTrend.topic}" (score: ${selectedTrend.score}, skipped ${skippedCount} topics)`);
+
+ // Step 2: Optional deep research on selected topic (fire-and-forget)
+ // When research is enabled, we submit to Gemini Deep Research
+ // but DON'T wait for it — the check-research cron will poll and enrich later
+ let researchInteractionId: string | undefined;
+ if (enableDeepResearch) {
+ console.log(`[CRON/ingest] Starting Gemini Deep Research on: "${selectedTrend.topic}"...`);
+ try {
+ const sourceUrls = (selectedTrend.signals ?? [])
+ .map((s: { url?: string }) => s.url)
+ .filter((u): u is string => !!u && u.startsWith("http"))
+ .slice(0, 5);
+
+ researchInteractionId = await submitResearch(selectedTrend.topic, { sourceUrls });
+ console.log(`[CRON/ingest] Deep Research submitted — interactionId: ${researchInteractionId}. check-research cron will poll.`);
+ } catch (err) {
+ console.warn("[CRON/ingest] Deep Research submission failed, continuing without:", err);
+ }
+ }
+
+ // Step 3: Generate script with Gemini (basic — without research data)
+ // When research is enabled, check-research will re-generate an enriched script later
+ console.log("[CRON/ingest] Generating script with Gemini...");
+ const prompt = buildPrompt([selectedTrend]);
+ const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
+
+ let script: GeneratedScript;
+ try {
+ // Strip markdown code fences if present (Gemini sometimes wraps JSON in ```json ... ```)
+ const cleaned = stripCodeFences(rawResponse);
+ script = JSON.parse(cleaned) as GeneratedScript;
+ } catch (parseErr) {
+ console.error(
+ "[CRON/ingest] Failed to parse Gemini response:",
+ rawResponse,
+ );
+ return Response.json(
+ {
+ error: "Failed to parse Gemini response",
+ raw: rawResponse.slice(0, 500),
+ },
+ { status: 502 },
+ );
+ }
+
+ console.log(`[CRON/ingest] Generated script: "${script.title}"`);
+
+ console.log("[CRON/ingest] Running critic pass...");
+ const criticResult = await claudeCritic(script);
+ console.log(
+ `[CRON/ingest] Critic score: ${criticResult.score}/100 — ${criticResult.summary}`,
+ );
+
+ console.log("[CRON/ingest] Creating Sanity documents...");
+ const result = await createSanityDocuments(script, criticResult, selectedTrend, qualityThreshold, undefined, researchInteractionId);
+
+ console.log("[CRON/ingest] Done!", result);
+
+ return Response.json({
+ success: true,
+ ...result,
+ title: script.title,
+ criticScore: criticResult.score,
+ trendCount: trends.length,
+ trendScore: selectedTrend.score,
+ skippedCount,
+ researchStarted: !!researchInteractionId,
+ researchInteractionId: researchInteractionId,
+ });
+ } catch (err) {
+ console.error("[CRON/ingest] Unexpected error:", err);
+ return Response.json(
+ {
+ success: false,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/cron/route.tsx b/apps/web/app/api/cron/route.tsx
new file mode 100644
index 000000000..2dce5fc0a
--- /dev/null
+++ b/apps/web/app/api/cron/route.tsx
@@ -0,0 +1,43 @@
+import { publicURL } from "@/lib/utils";
+import type { NextRequest } from "next/server";
+
+export function GET(request: NextRequest) {
+ const authHeader = request.headers.get("authorization");
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
+ console.error("[CRON] Unauthorized request: invalid authorization header");
+ return new Response("Unauthorized", {
+ status: 401,
+ });
+ }
+ try {
+ // Forward the action param if present (discover, fetch, sync)
+ const action = request.nextUrl.searchParams.get("action");
+ const params = action ? `?action=${action}` : "";
+ const url = `${publicURL()}/api/youtube/views${params}`;
+ console.log("[CRON] Triggering YouTube views update:", url);
+ fetch(url, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${process.env.CRON_SECRET}`,
+ "Cache-Control": "no-cache",
+ },
+ })
+ .then((res) => {
+ if (!res.ok) {
+ console.error("[CRON] Failed to trigger YouTube views:", res.status);
+ } else {
+ console.log("[CRON] Successfully triggered YouTube views update.");
+ }
+ })
+ .catch((err) => {
+ console.error("[CRON] Error triggering YouTube views:", err);
+ });
+ return Response.json({ success: true });
+ } catch (err) {
+ console.error("[CRON] Unexpected error:", err);
+ return Response.json(
+ { success: false, error: String(err) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/cron/sponsor-outreach/route.ts b/apps/web/app/api/cron/sponsor-outreach/route.ts
new file mode 100644
index 000000000..fb40d13f7
--- /dev/null
+++ b/apps/web/app/api/cron/sponsor-outreach/route.ts
@@ -0,0 +1,137 @@
+import { NextResponse } from 'next/server'
+import { sanityWriteClient } from '@/lib/sanity-write-client'
+import { generateOutreachEmail } from '@/lib/sponsor/gemini-outreach'
+import { sendSponsorEmail } from '@/lib/sponsor/email-service'
+import type { SponsorPoolEntry } from '@/lib/sponsor/gemini-outreach'
+import { getConfig } from '@/lib/config'
+
+export async function POST(request: Request) {
+ // Auth: Bearer token check against CRON_SECRET
+ const cronSecret = process.env.CRON_SECRET;
+ if (!cronSecret) {
+ console.error('[SPONSOR] CRON_SECRET not configured');
+ return new Response('Server misconfigured', { status: 503 });
+ }
+ const authHeader = request.headers.get('authorization')
+ if (authHeader !== `Bearer ${cronSecret}`) {
+ console.error('[SPONSOR] Outreach cron: unauthorized request')
+ return new Response('Unauthorized', { status: 401 })
+ }
+
+ try {
+ console.log('[SPONSOR] Starting outbound sponsor outreach cron...')
+
+ // Fetch config from Sanity singleton
+ const sponsorCfg = await getConfig("sponsor_config");
+ const maxPerRun = sponsorCfg.maxOutreachPerRun;
+ const cooldownDays = sponsorCfg.cooldownDays;
+
+ // Build rate card string from config tiers
+ const rateCard = sponsorCfg.rateCardTiers
+ .map((t) => `- ${t.name} ($${t.price}) — ${t.description}`)
+ .join('\n');
+
+ // Calculate the cutoff date for cooldown
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - cooldownDays)
+ const cutoffISO = cutoffDate.toISOString()
+
+ // Query Sanity for eligible sponsor pool entries
+ const query = `*[
+ _type == "sponsorPool"
+ && optedOut != true
+ && (
+ !defined(lastContactedAt)
+ || lastContactedAt < $cutoffDate
+ )
+ ] | order(relevanceScore desc) [0...${maxPerRun - 1}] {
+ _id,
+ companyName,
+ contactName,
+ contactEmail,
+ website,
+ category,
+ relevanceScore,
+ optOutToken
+ }`
+
+ const sponsors: SponsorPoolEntry[] = await sanityWriteClient.fetch(query, {
+ cutoffDate: cutoffISO,
+ })
+
+ console.log(`[SPONSOR] Found ${sponsors.length} eligible sponsors for outreach`)
+
+ if (sponsors.length === 0) {
+ return NextResponse.json({
+ success: true,
+ message: 'No eligible sponsors for outreach',
+ processed: 0,
+ })
+ }
+
+ const results: Array<{ companyName: string; success: boolean; error?: string }> = []
+
+ for (const sponsor of sponsors) {
+ try {
+ // Generate personalized outreach email with config rate card
+ const email = await generateOutreachEmail(sponsor, rateCard)
+
+ // Send the email (stubbed)
+ const sendResult = await sendSponsorEmail(
+ sponsor.contactEmail,
+ email.subject,
+ email.body
+ )
+
+ if (sendResult.success) {
+ // Update lastContactedAt on the sponsor pool entry
+ await sanityWriteClient
+ .patch(sponsor._id)
+ .set({ lastContactedAt: new Date().toISOString() })
+ .commit()
+
+ // Create a sponsorLead with source='outbound'
+ await sanityWriteClient.create({
+ _type: 'sponsorLead',
+ companyName: sponsor.companyName,
+ contactName: sponsor.contactName,
+ contactEmail: sponsor.contactEmail,
+ source: 'outbound',
+ status: 'contacted',
+ threadId: crypto.randomUUID(),
+ lastEmailAt: new Date().toISOString(),
+ })
+
+ results.push({ companyName: sponsor.companyName, success: true })
+ console.log(`[SPONSOR] Outreach sent to: ${sponsor.companyName}`)
+ } else {
+ results.push({
+ companyName: sponsor.companyName,
+ success: false,
+ error: 'Email send failed',
+ })
+ }
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ console.error(`[SPONSOR] Outreach failed for ${sponsor.companyName}:`, errorMsg)
+ results.push({ companyName: sponsor.companyName, success: false, error: errorMsg })
+ }
+ }
+
+ const successCount = results.filter((r) => r.success).length
+ console.log(`[SPONSOR] Outreach cron complete: ${successCount}/${results.length} successful`)
+
+ return NextResponse.json({
+ success: true,
+ processed: results.length,
+ successful: successCount,
+ results,
+ })
+ } catch (error) {
+ console.error('[SPONSOR] Outreach cron error:', error)
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/web/app/api/dashboard/activity/route.ts b/apps/web/app/api/dashboard/activity/route.ts
new file mode 100644
index 000000000..0af91661b
--- /dev/null
+++ b/apps/web/app/api/dashboard/activity/route.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import type { ActivityItem } from "@/lib/types/dashboard";
+
+export async function GET() {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ const items = await dashboardQuery(`
+ *[_type in ["contentIdea", "automatedVideo", "sponsorLead"]] | order(_updatedAt desc) [0..9] {
+ _id,
+ _type,
+ _updatedAt,
+ title,
+ companyName,
+ status
+ }
+ `);
+
+ return NextResponse.json(items ?? []);
+ } catch (error) {
+ console.error("Failed to fetch activity:", error);
+ return NextResponse.json([], { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/dashboard/config/route.ts b/apps/web/app/api/dashboard/config/route.ts
new file mode 100644
index 000000000..507df617a
--- /dev/null
+++ b/apps/web/app/api/dashboard/config/route.ts
@@ -0,0 +1,175 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { getEngineConfig, invalidateEngineConfig } from "@/lib/config";
+import { writeClient } from "@/lib/sanity-write-client";
+
+const CONFIG_DOC_ID = "engineConfig";
+
+// Whitelist of fields that can be updated via the API
+const ALLOWED_FIELDS = new Set([
+ "autoPublish",
+ "qualityThreshold",
+ "reviewTimeoutDays",
+ "maxIdeasPerRun",
+ "longFormPerWeek",
+ "shortsPerDay",
+ "blogsPerWeek",
+ "publishDays",
+ "contentCategories",
+ "geminiModel",
+ "infographicModel",
+ "systemInstruction",
+ "youtubeEnabled",
+ "twitterEnabled",
+ "linkedinEnabled",
+ "tiktokEnabled",
+ "instagramEnabled",
+ "blueskyEnabled",
+ "youtubeUploadVisibility",
+ "notificationEmails",
+ "cooldownDays",
+ "rateCardTiers",
+ "maxOutreachPerRun",
+ "brandPrimary",
+ "brandBackground",
+ "brandText",
+]);
+
+async function requireAuth() {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ return {
+ error: NextResponse.json(
+ { error: "Auth not configured" },
+ { status: 503 }
+ ),
+ };
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return {
+ error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
+ };
+ }
+
+ return { user };
+}
+
+export async function GET() {
+ const auth = await requireAuth();
+ if ("error" in auth && auth.error) return auth.error;
+
+ try {
+ const config = await getEngineConfig();
+ return NextResponse.json(config);
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : "Failed to fetch config" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function PUT(request: Request) {
+ const auth = await requireAuth();
+ if ("error" in auth && auth.error) return auth.error;
+
+ try {
+ const body = await request.json();
+ if (!body || typeof body !== "object") {
+ return NextResponse.json(
+ { error: "Invalid request body" },
+ { status: 400 }
+ );
+ }
+
+ // Filter to only allowed fields
+ const sanitized: Record = {};
+ for (const [key, value] of Object.entries(body)) {
+ if (ALLOWED_FIELDS.has(key)) {
+ sanitized[key] = value;
+ }
+ }
+
+ if (Object.keys(sanitized).length === 0) {
+ return NextResponse.json(
+ { error: "No valid fields to update" },
+ { status: 400 }
+ );
+ }
+
+ // Validate specific field types
+ if ("qualityThreshold" in sanitized) {
+ const v = Number(sanitized.qualityThreshold);
+ if (!Number.isFinite(v) || v < 0 || v > 100) {
+ return NextResponse.json(
+ { error: "qualityThreshold must be between 0 and 100" },
+ { status: 400 }
+ );
+ }
+ sanitized.qualityThreshold = v;
+ }
+
+ if ("publishDays" in sanitized) {
+ const validDays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+ if (
+ !Array.isArray(sanitized.publishDays) ||
+ !sanitized.publishDays.every(
+ (d: unknown) => typeof d === "string" && validDays.includes(d as string)
+ )
+ ) {
+ return NextResponse.json(
+ { error: "publishDays must be an array of valid day abbreviations" },
+ { status: 400 }
+ );
+ }
+ }
+
+ if ("rateCardTiers" in sanitized) {
+ if (!Array.isArray(sanitized.rateCardTiers)) {
+ return NextResponse.json(
+ { error: "rateCardTiers must be an array" },
+ { status: 400 }
+ );
+ }
+ // Ensure _key on each tier for Sanity arrays
+ sanitized.rateCardTiers = (sanitized.rateCardTiers as any[]).map(
+ (tier, i) => ({
+ _key: tier._key || `tier-${i}`,
+ _type: "rateCardTier",
+ name: String(tier.name || ""),
+ description: String(tier.description || ""),
+ price: Number(tier.price || 0),
+ })
+ );
+ }
+
+ if ("autoPublish" in sanitized) {
+ sanitized.autoPublish = Boolean(sanitized.autoPublish);
+ }
+
+ // Patch the singleton
+ await writeClient
+ .patch(CONFIG_DOC_ID)
+ .set(sanitized)
+ .commit();
+
+ // Invalidate the in-memory cache
+ invalidateEngineConfig();
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : "Failed to update config" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/dashboard/metrics/route.ts b/apps/web/app/api/dashboard/metrics/route.ts
new file mode 100644
index 000000000..fdcc86e91
--- /dev/null
+++ b/apps/web/app/api/dashboard/metrics/route.ts
@@ -0,0 +1,47 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+import type { DashboardMetrics } from "@/lib/types/dashboard";
+
+export async function GET() {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ const counts = await dashboardQuery>(`{
+ "videosPublished": count(*[_type == "automatedVideo" && status == "published"]),
+ "flaggedVideos": count(*[_type == "automatedVideo" && status == "flagged"]),
+ "newIdeas": count(*[_type == "contentIdea" && status == "new"]),
+ "sponsorPipeline": count(*[_type == "sponsorLead" && status != "paid"])
+ }`);
+
+ const metrics: DashboardMetrics = {
+ videosPublished: counts?.videosPublished ?? 0,
+ flaggedForReview: (counts?.flaggedVideos ?? 0) + (counts?.newIdeas ?? 0),
+ sponsorPipeline: counts?.sponsorPipeline ?? 0,
+ revenue: null,
+ };
+
+ return NextResponse.json(metrics);
+ } catch (error) {
+ console.error("Failed to fetch dashboard metrics:", error);
+ return NextResponse.json(
+ { videosPublished: 0, flaggedForReview: 0, sponsorPipeline: 0, revenue: null },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/dashboard/pipeline/route.ts b/apps/web/app/api/dashboard/pipeline/route.ts
new file mode 100644
index 000000000..f2deb4307
--- /dev/null
+++ b/apps/web/app/api/dashboard/pipeline/route.ts
@@ -0,0 +1,46 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+
+export async function GET() {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ // Single consolidated GROQ query for all pipeline stages
+ const counts = await dashboardQuery>(`{
+ "draft": count(*[_type == "automatedVideo" && status == "draft"]),
+ "scriptReady": count(*[_type == "automatedVideo" && status == "script_ready"]),
+ "audioGen": count(*[_type == "automatedVideo" && status == "audio_gen"]),
+ "rendering": count(*[_type == "automatedVideo" && status == "rendering"]),
+ "videoGen": count(*[_type == "automatedVideo" && status == "video_gen"]),
+ "flagged": count(*[_type == "automatedVideo" && status == "flagged"]),
+ "uploading": count(*[_type == "automatedVideo" && status == "uploading"]),
+ "published": count(*[_type == "automatedVideo" && status == "published"])
+ }`);
+
+ const total = Object.values(counts ?? {}).reduce((sum, n) => sum + (n ?? 0), 0);
+
+ return NextResponse.json({
+ ...counts,
+ total,
+ });
+ } catch (error) {
+ console.error("Failed to fetch pipeline status:", error);
+ return NextResponse.json({ error: "Failed" }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/dashboard/review/route.ts b/apps/web/app/api/dashboard/review/route.ts
new file mode 100644
index 000000000..9a61cd26d
--- /dev/null
+++ b/apps/web/app/api/dashboard/review/route.ts
@@ -0,0 +1,115 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { writeClient } from "@/lib/sanity-write-client";
+
+async function requireAuth() {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ return {
+ error: NextResponse.json(
+ { error: "Auth not configured" },
+ { status: 503 }
+ ),
+ };
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return {
+ error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
+ };
+ }
+
+ return { user };
+}
+
+export async function POST(request: Request) {
+ const auth = await requireAuth();
+ if ("error" in auth && auth.error) return auth.error;
+
+ try {
+ const body = await request.json();
+
+ // Validate input
+ const { videoId, action, reason } = body;
+
+ if (!videoId || typeof videoId !== "string") {
+ return NextResponse.json(
+ { error: "videoId is required and must be a string" },
+ { status: 400 }
+ );
+ }
+
+ if (action !== "approve" && action !== "reject") {
+ return NextResponse.json(
+ { error: "action must be 'approve' or 'reject'" },
+ { status: 400 }
+ );
+ }
+
+ if (action === "reject" && (!reason || typeof reason !== "string" || !reason.trim())) {
+ return NextResponse.json(
+ { error: "reason is required for rejection" },
+ { status: 400 }
+ );
+ }
+
+ const now = new Date().toISOString();
+
+ if (action === "approve") {
+ await writeClient
+ .patch(videoId)
+ .set({
+ status: "approved",
+ reviewedAt: now,
+ reviewedBy: auth.user?.email || "dashboard-user",
+ })
+ .commit();
+
+ // Fire webhook to CF Workflow if configured
+ const cfWorkersUrl = process.env.CF_WORKERS_URL;
+ if (cfWorkersUrl) {
+ try {
+ await fetch(cfWorkersUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ workflowId: videoId,
+ action: "approved",
+ }),
+ });
+ } catch (webhookErr) {
+ // Log but don't fail the approval
+ console.error("[review-api] CF Workflow webhook failed:", webhookErr);
+ }
+ }
+
+ return NextResponse.json({ success: true, status: "approved" });
+ }
+
+ // Reject
+ await writeClient
+ .patch(videoId)
+ .set({
+ status: "rejected",
+ flaggedReason: reason.trim(),
+ reviewedAt: now,
+ reviewedBy: auth.user?.email || "dashboard-user",
+ })
+ .commit();
+
+ return NextResponse.json({ success: true, status: "rejected" });
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : "Failed to process review" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/dashboard/settings/route.ts b/apps/web/app/api/dashboard/settings/route.ts
new file mode 100644
index 000000000..2958e70c5
--- /dev/null
+++ b/apps/web/app/api/dashboard/settings/route.ts
@@ -0,0 +1,150 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { dashboardQuery, dashboardClient } from "@/lib/sanity/dashboard";
+
+const SETTINGS_DOC_ID = "dashboardSettings";
+
+const DEFAULT_SETTINGS = {
+ videosPerWeek: 3,
+ publishDays: ["Mon", "Wed", "Fri"],
+ contentCategories: [
+ "JavaScript", "TypeScript", "React", "Next.js", "Angular",
+ "Svelte", "Node.js", "CSS", "DevOps", "AI / ML",
+ "Web Performance", "Tooling",
+ ],
+ rateCardTiers: [
+ { name: "Pre-roll Mention", description: "15-second sponsor mention", price: 200 },
+ { name: "Mid-roll Segment", description: "60-second dedicated segment", price: 500 },
+ { name: "Dedicated Video", description: "Full sponsored video", price: 1500 },
+ ],
+};
+
+async function requireAuth() {
+ const hasSupabase =
+ (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
+ (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
+
+ if (!hasSupabase) {
+ return { error: NextResponse.json({ error: "Auth not configured" }, { status: 503 }) };
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
+ }
+
+ return { user };
+}
+
+const VALID_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+
+function validateSettings(body: unknown): { valid: boolean; data?: Record; error?: string } {
+ if (!body || typeof body !== "object") {
+ return { valid: false, error: "Invalid request body" };
+ }
+
+ const input = body as Record;
+ const sanitized: Record = {};
+
+ if ("videosPerWeek" in input) {
+ const v = Number(input.videosPerWeek);
+ if (!Number.isInteger(v) || v < 1 || v > 14) {
+ return { valid: false, error: "videosPerWeek must be an integer between 1 and 14" };
+ }
+ sanitized.videosPerWeek = v;
+ }
+
+ if ("publishDays" in input) {
+ if (!Array.isArray(input.publishDays) || !input.publishDays.every((d: unknown) => typeof d === "string" && VALID_DAYS.includes(d as string))) {
+ return { valid: false, error: "publishDays must be an array of valid day abbreviations" };
+ }
+ sanitized.publishDays = input.publishDays;
+ }
+
+ if ("contentCategories" in input) {
+ if (!Array.isArray(input.contentCategories) || !input.contentCategories.every((c: unknown) => typeof c === "string" && (c as string).length <= 50)) {
+ return { valid: false, error: "contentCategories must be an array of strings (max 50 chars each)" };
+ }
+ sanitized.contentCategories = input.contentCategories;
+ }
+
+ if ("rateCardTiers" in input) {
+ if (!Array.isArray(input.rateCardTiers)) {
+ return { valid: false, error: "rateCardTiers must be an array" };
+ }
+ for (const tier of input.rateCardTiers as Record[]) {
+ if (typeof tier.name !== "string" || typeof tier.description !== "string" || typeof tier.price !== "number") {
+ return { valid: false, error: "Each rate card tier must have name (string), description (string), and price (number)" };
+ }
+ }
+ sanitized.rateCardTiers = (input.rateCardTiers as Record[]).map((t) => ({
+ _type: "object",
+ _key: crypto.randomUUID().slice(0, 8),
+ name: t.name,
+ description: t.description,
+ price: t.price,
+ }));
+ }
+
+ if (Object.keys(sanitized).length === 0) {
+ return { valid: false, error: "No valid fields provided" };
+ }
+
+ return { valid: true, data: sanitized };
+}
+
+export async function GET() {
+ const auth = await requireAuth();
+ if (auth.error) return auth.error;
+
+ try {
+ const settings = await dashboardQuery(
+ `*[_type == "dashboardSettings"][0] {
+ videosPerWeek,
+ publishDays,
+ contentCategories,
+ rateCardTiers[] { name, description, price }
+ }`
+ );
+ return NextResponse.json(settings ?? DEFAULT_SETTINGS);
+ } catch (error) {
+ console.error("Failed to fetch settings:", error);
+ return NextResponse.json({ error: "Failed to fetch settings" }, { status: 500 });
+ }
+}
+
+export async function PUT(request: Request) {
+ const auth = await requireAuth();
+ if (auth.error) return auth.error;
+
+ if (!dashboardClient) {
+ return NextResponse.json({ error: "Sanity client not available" }, { status: 503 });
+ }
+
+ try {
+ const body = await request.json();
+ const validation = validateSettings(body);
+
+ if (!validation.valid) {
+ return NextResponse.json({ error: validation.error }, { status: 400 });
+ }
+
+ // Use createIfNotExists with deterministic ID to prevent race conditions
+ await dashboardClient.createIfNotExists({
+ _id: SETTINGS_DOC_ID,
+ _type: "dashboardSettings",
+ ...DEFAULT_SETTINGS,
+ });
+
+ await dashboardClient.patch(SETTINGS_DOC_ID).set(validation.data!).commit();
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Failed to update settings:", error);
+ return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/devto/route.tsx b/apps/web/app/api/devto/route.tsx
new file mode 100644
index 000000000..a4c1ade99
--- /dev/null
+++ b/apps/web/app/api/devto/route.tsx
@@ -0,0 +1,253 @@
+import type { PodcastQueryResult } from "@/sanity/types";
+import { podcastQuery, postQuery } from "@/sanity/lib/queries";
+import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
+import toMarkdown from "@sanity/block-content-to-markdown";
+import { createClient } from "next-sanity";
+import { urlForImage } from "@/sanity/lib/utils";
+
+const secret = process.env.PRIVATE_SYNDICATE_WEBOOK_SECRET;
+import { apiVersion, dataset, projectId, studioUrl } from "@/sanity/lib/api";
+
+const sanityWriteClient = createClient({
+ projectId,
+ dataset,
+ token: process.env.SANITY_API_WRITE_TOKEN,
+ apiVersion,
+ perspective: "published",
+ useCdn: false,
+});
+
+export async function POST(request: Request) {
+ if (!secret)
+ return Response.json(
+ {
+ success: false,
+ error: "Missing Secret PRIVATE_SYNDICATE_WEBOOK_SECRET",
+ },
+ { status: 400 },
+ );
+
+ const signature = request.headers.get(SIGNATURE_HEADER_NAME);
+
+ if (!signature)
+ return Response.json(
+ { success: false, error: "Missing Signature Header" },
+ { status: 401 },
+ );
+
+ const body = await request.text();
+ if (!(await isValidSignature(body, signature, secret))) {
+ return Response.json(
+ { success: false, message: "Invalid signature" },
+ { status: 400 },
+ );
+ }
+
+ const sanityDoc = JSON.parse(body);
+ const created = sanityDoc.created;
+ const deleted = sanityDoc.deleted;
+ const updated = sanityDoc.updated;
+
+ try {
+ if (created) {
+ await formatPodcast(sanityDoc._type, sanityDoc.slug);
+ } else if (updated) {
+ await formatPodcast(sanityDoc._type, sanityDoc.slug);
+ } else {
+ await unPublishPodcast(sanityDoc._type, sanityDoc._id, sanityDoc.devto);
+ }
+ } catch (e) {
+ const error = JSON.stringify(e);
+ console.error(error);
+ Response.json({ success: false, error }, { status: 400 });
+ }
+ return Response.json({ success: true });
+}
+
+const formatPodcast = async (_type: string, slug: string) => {
+ const podcast = await sanityQuery(_type, slug);
+
+ if (!podcast?._id) {
+ return Response.json(
+ { success: false, error: "Podcast not found" },
+ { status: 404 },
+ );
+ }
+
+ console.log("Adding", { slug: podcast?.slug, devto: podcast?.devto });
+
+ try {
+ const article: any = {
+ article: {
+ title: podcast.title,
+ published: true,
+ tags: ["webdev", "javascript", "beginners"],
+ main_image: urlForImage(podcast?.coverImage)?.width(1000).height(420).url() || "",
+ canonical_url: `https://codingcat.dev/${podcast._type}/${podcast.slug}`,
+ description: podcast?.excerpt || "",
+ organization_id: "1009",
+ body_markdown: `
+Original: https://codingcat.dev/${podcast._type}/${podcast.slug}
+
+${podcast?.youtube ? `{% youtube ${podcast?.youtube?.replace("live", "embed")} %}` : ``}
+
+${toMarkdown(podcast.content, { serializers })}`,
+ },
+ };
+
+ if (_type === "podcast") {
+ article.article.tags.push("podcast");
+ article.article.series = `codingcatdev_podcast_${podcast?.season || 4}`;
+ }
+ console.log("article", JSON.stringify(article, null, 2));
+
+ let response;
+ if (podcast?.devto) {
+ console.log("updateArticle to devto");
+ response = await updateArticle(podcast.devto, article);
+ console.log("updateArticle result:", response.status);
+ } else {
+ console.log("addArticle to devto");
+ response = await addArticle(article);
+ console.log("addArticle result:", response.status);
+ }
+
+ const json = await response.json();
+ console.log("result payload", JSON.stringify(json, null, 2));
+
+ // Get new devto url and update
+ if (response.status >= 200 && response.status <= 299) {
+ if (json?.url && !podcast?.devto) {
+ console.log("Article Added to Dev.to", JSON.stringify(json, null, 2));
+ await updateSanity(podcast._id, json.url);
+ console.log("Sanity Updated", podcast._id, json.url);
+ }
+ }
+ return Response.json({ success: true }, { status: 201 });
+ } catch (error) {
+ console.error(error);
+ return Response.json({ success: false, error }, { status: 500 });
+ }
+};
+
+const unPublishPodcast = async (_type: string, id: string, devto: string) => {
+ if (!id) {
+ return Response.json(
+ { success: false, error: "Podcast not found" },
+ { status: 404 },
+ );
+ }
+
+ console.log("Unpublishing", { _type, id, devto });
+
+ try {
+ if (!devto) {
+ return Response.json(
+ { success: false, error: "DevTo not found" },
+ { status: 404 },
+ );
+ }
+ const response = await unpublishArticle(devto);
+
+ // Remove devto from sanity
+ if (response.status >= 200 && response.status <= 299) {
+ console.log("removed post from devto");
+ return Response.json({ success: true }, { status: 200 });
+ }
+ return Response.json({ success: true }, { status: 200 });
+ } catch (error) {
+ console.error(error);
+ return Response.json({ success: false, error }, { status: 500 });
+ }
+};
+
+const addArticle = async (article: any) => {
+ return fetch("https://dev.to/api/articles/", {
+ method: "POST",
+ headers: {
+ "api-key": process.env.PRIVATE_DEVTO || "",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(article),
+ });
+};
+
+const getArticle = async (devto: any) => {
+ return fetch(
+ `https://dev.to/api/articles/${devto.split("https://dev.to/").at(-1)}`,
+ {
+ method: "GET",
+ headers: {
+ "api-key": process.env.PRIVATE_DEVTO || "",
+ "Content-Type": "application/json",
+ },
+ },
+ );
+};
+
+const updateArticle = async (devto: string, article: any) => {
+ const requestedArticle = await getArticle(devto);
+ const json = await requestedArticle.json();
+ return fetch(`https://dev.to/api/articles/${json?.id}`, {
+ method: "PUT",
+ headers: {
+ "api-key": process.env.PRIVATE_DEVTO || "",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(article),
+ });
+};
+
+const unpublishArticle = async (devto: string) => {
+ const requestedArticle = await getArticle(devto);
+ const json = await requestedArticle.json();
+ return fetch(`https://dev.to/api/articles/${json?.id}/unpublish`, {
+ method: "PUT",
+ headers: {
+ "api-key": process.env.PRIVATE_DEVTO || "",
+ "Content-Type": "application/json",
+ },
+ });
+};
+
+const updateSanity = async (_id: string, url: string) => {
+ return sanityWriteClient.patch(_id).set({ devto: url }).commit();
+};
+
+const sanityQuery = async (_type: string, slug: string) => {
+ const query = _type === "podcast" ? podcastQuery : postQuery;
+ const [podcast] = await Promise.all([
+ sanityWriteClient.fetch(
+ query,
+ {
+ slug,
+ },
+ {
+ stega: false,
+ perspective: "raw",
+ useCdn: false,
+ },
+ ),
+ ]);
+ return podcast;
+};
+
+// Check base.ts for the custom types
+const serializers = {
+ types: {
+ code: (props: any) =>
+ "```" + props?.node?.language + "\n" + props?.node?.code + "\n```",
+ image: (props: any) => {
+ const url = props?.node?.asset?._ref
+ ? urlForImage(props.node)?.url()
+ : "";
+ return ``;
+ },
+ codepen: (props: any) => `{% codepen ${props?.node?.url} %}`,
+ codesandbox: (props: any) =>
+ `{% codesandbox ${props?.node?.url?.split("https://codesandbox.io/p/sandbox/")?.at(-1)} %}`,
+ twitter: (props: any) => `{% twitter ${props?.node?.id} %}`,
+ quote: (props: any) =>
+ `> ${toMarkdown(props?.node?.content, { serializers })}`,
+ },
+};
diff --git a/apps/web/app/api/draft-mode/disable/route.tsx b/apps/web/app/api/draft-mode/disable/route.tsx
new file mode 100644
index 000000000..f438409f3
--- /dev/null
+++ b/apps/web/app/api/draft-mode/disable/route.tsx
@@ -0,0 +1,9 @@
+// src/app/api/draft-mode/disable/route.ts
+
+import { draftMode } from "next/headers";
+import { type NextRequest, NextResponse } from "next/server";
+
+export async function GET(request: NextRequest) {
+ (await draftMode()).disable();
+ return NextResponse.redirect(new URL("/", request.url));
+}
diff --git a/apps/web/app/api/draft-mode/enable/route.tsx b/apps/web/app/api/draft-mode/enable/route.tsx
new file mode 100644
index 000000000..dbdfb6d17
--- /dev/null
+++ b/apps/web/app/api/draft-mode/enable/route.tsx
@@ -0,0 +1,7 @@
+import { defineEnableDraftMode } from "next-sanity/draft-mode";
+import { client } from "@/sanity/lib/client";
+import { token } from "@/sanity/lib/token";
+
+export const { GET } = defineEnableDraftMode({
+ client: client.withConfig({ token, useCdn: false }),
+});
diff --git a/apps/web/app/api/generate-preview-token/route.ts b/apps/web/app/api/generate-preview-token/route.ts
new file mode 100644
index 000000000..932badec4
--- /dev/null
+++ b/apps/web/app/api/generate-preview-token/route.ts
@@ -0,0 +1,44 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+import { v4 as uuidv4 } from "uuid";
+import { createClient } from "next-sanity";
+import { apiVersion, dataset, projectId } from "@/sanity/lib/api";
+
+// Set this in your environment variables for security
+const SHARED_SECRET = process.env.NEXT_PUBLIC_PREVIEW_TOKEN_SECRET;
+const sanityClient = createClient({
+ projectId,
+ dataset,
+ apiVersion,
+ token: process.env.SANITY_API_WRITE_TOKEN, // Must have write access
+ useCdn: false,
+});
+
+export async function POST(req: NextRequest) {
+ const { documentId, secret } = await req.json();
+ // check if authorized
+ console.log(
+ "Received request to generate preview token for document ID:",
+ documentId,
+ );
+ console.log("Using secret:", secret);
+ console.log("Expected secret:", SHARED_SECRET);
+
+ if (!documentId || !secret || secret !== SHARED_SECRET) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const token = uuidv4();
+ const expiresAt = new Date(
+ Date.now() + 1000 * 60 * 60 * 24 * 7,
+ ).toISOString(); // 7 days
+
+ // Create previewSession document in Sanity
+ await sanityClient.create({
+ _type: "previewSession",
+ token,
+ documentId,
+ expiresAt,
+ });
+
+ return NextResponse.json({ token, expiresAt });
+}
diff --git a/apps/web/app/api/get-preview-content/route.ts b/apps/web/app/api/get-preview-content/route.ts
new file mode 100644
index 000000000..fef44e4ab
--- /dev/null
+++ b/apps/web/app/api/get-preview-content/route.ts
@@ -0,0 +1,64 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+import { createClient } from "next-sanity";
+import { podcastQuery, postQuery } from "@/sanity/lib/queries";
+import { apiVersion, dataset, projectId, studioUrl } from "@/sanity/lib/api";
+
+const sanityClient = createClient({
+ projectId,
+ dataset,
+ apiVersion,
+ token: process.env.SANITY_API_READ_TOKEN,
+ useCdn: false,
+ perspective: "drafts",
+});
+
+export async function POST(req: NextRequest) {
+ const { token } = await req.json();
+ if (!token) {
+ return NextResponse.json({ error: "Token required" }, { status: 400 });
+ }
+
+ // Find previewSession by token
+ const session = await sanityClient.fetch(
+ '*[_type == "previewSession" && token == $token && (!defined(expiresAt) || expiresAt > now())][0]',
+ { token },
+ );
+
+ if (!session) {
+ return NextResponse.json(
+ { error: "Invalid or expired token" },
+ { status: 404 },
+ );
+ }
+
+ // Fetch the draft document (post or podcast)
+ const doc = await sanityClient.fetch(
+ '*[_id == $docId && (_type == "post" || _type == "podcast")][0]',
+ { docId: session.documentId },
+ );
+
+ if (!doc) {
+ return NextResponse.json({ error: "Document not found" }, { status: 404 });
+ }
+
+ if (doc?._type !== "post" && doc?._type !== "podcast" && !doc.slug?.current) {
+ return NextResponse.json({ error: "Document not found" }, { status: 404 });
+ }
+
+ if (doc?._type === "podcast") {
+ return NextResponse.json({
+ document: await sanityClient.fetch(podcastQuery, {
+ slug: doc.slug.current,
+ }),
+ });
+ }
+
+ if (doc?._type === "post") {
+ return NextResponse.json({
+ document: await sanityClient.fetch(postQuery, { slug: doc.slug.current }),
+ });
+ }
+
+ return NextResponse.json({ error: "Document not found" }, { status: 404 });
+}
diff --git a/apps/web/app/api/hashnode/route.tsx b/apps/web/app/api/hashnode/route.tsx
new file mode 100644
index 000000000..8795cb8ae
--- /dev/null
+++ b/apps/web/app/api/hashnode/route.tsx
@@ -0,0 +1,361 @@
+import type { PodcastQueryResult } from "@/sanity/types";
+import { podcastQuery, postQuery } from "@/sanity/lib/queries";
+import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
+import toMarkdown from "@sanity/block-content-to-markdown";
+import { createClient } from "next-sanity";
+import { urlForImage } from "@/sanity/lib/utils";
+import { apiVersion, dataset, projectId, studioUrl } from "@/sanity/lib/api";
+
+const secret = process.env.PRIVATE_SYNDICATE_WEBOOK_SECRET;
+
+const sanityWriteClient = createClient({
+ projectId,
+ dataset,
+ apiVersion,
+ token: process.env.SANITY_API_WRITE_TOKEN,
+ perspective: "published",
+ useCdn: false,
+});
+
+export async function POST(request: Request) {
+ if (!secret)
+ return Response.json(
+ {
+ success: false,
+ error: "Missing Secret PRIVATE_SYNDICATE_WEBOOK_SECRET",
+ },
+ { status: 400 },
+ );
+
+ const signature = request.headers.get(SIGNATURE_HEADER_NAME);
+
+ if (!signature)
+ return Response.json(
+ { success: false, error: "Missing Signature Header" },
+ { status: 401 },
+ );
+
+ const body = await request.text();
+ if (!(await isValidSignature(body, signature, secret))) {
+ return Response.json(
+ { success: false, message: "Invalid signature" },
+ { status: 400 },
+ );
+ }
+
+ const sanityDoc = JSON.parse(body);
+ const created = sanityDoc.created;
+ const deleted = sanityDoc.deleted;
+ const updated = sanityDoc.updated;
+
+ delete sanityDoc.created;
+ delete sanityDoc.deleted;
+ delete sanityDoc.updated;
+
+ try {
+ if (created) {
+ await formatPodcast(sanityDoc._type, sanityDoc.slug);
+ } else if (updated) {
+ await formatPodcast(sanityDoc._type, sanityDoc.slug);
+ } else {
+ await unPublishPodcast(
+ sanityDoc._type,
+ sanityDoc._id,
+ sanityDoc.hashnode,
+ );
+ }
+ } catch (e) {
+ const error = JSON.stringify(e);
+ console.error(error);
+ Response.json({ success: false, error }, { status: 400 });
+ }
+ return Response.json({ success: true });
+}
+
+const formatPodcast = async (_type: string, slug: string) => {
+ const podcast = await sanityQuery(_type, slug);
+
+ if (!podcast?._id) {
+ return Response.json(
+ { success: false, error: "Podcast not found" },
+ { status: 404 },
+ );
+ }
+
+ console.log("Adding", { slug: podcast?.slug, hashnode: podcast?.hashnode });
+
+ try {
+ const article: any = {
+ title: podcast.title,
+ subtitle:
+ podcast?.excerpt?.length && podcast?.excerpt?.length > 250
+ ? podcast?.excerpt?.substring(0, 247) + "..."
+ : podcast?.excerpt || "",
+ publicationId: "60242f8180da6c44eadf775b",
+ slug: `${podcast._type}-${podcast.slug}`,
+ tags: [
+ {
+ id: "56744721958ef13879b94cad",
+ name: "JavaScript",
+ slug: "javascript",
+ },
+ {
+ id: "56744722958ef13879b94f1b",
+ name: "Web Development",
+ slug: "web-development",
+ },
+ {
+ id: "56744723958ef13879b955a9",
+ name: "Beginner Developers",
+ slug: "beginners",
+ },
+ ],
+ coverImageOptions: {
+ coverImageURL: urlForImage(podcast?.coverImage)?.width(1600).height(840).url() || "",
+ },
+ originalArticleURL: `https://codingcat.dev/${podcast._type}/${podcast.slug}`,
+ contentMarkdown: `
+Original: https://codingcat.dev/${podcast._type}/${podcast.slug}
+
+${podcast?.youtube ? "%[" + podcast?.youtube?.replace("live", "embed") + "]" : ""}
+
+${toMarkdown(podcast.content, { serializers })}`,
+ };
+
+ if (_type === "podcast") {
+ article.tags.push({
+ id: "56744722958ef13879b950d3",
+ name: "podcast",
+ slug: "podcast",
+ });
+ article.seriesId = "65a9ad4ef60adbf4aeedd0a2";
+ }
+ console.log("article", JSON.stringify(article, null, 2));
+
+ let response;
+ if (podcast?.hashnode) {
+ console.log("updateArticle to hashnode");
+ response = await updateArticle(podcast.hashnode, article);
+ console.log("updateArticle result:", response.status);
+ } else {
+ console.log("addArticle to hashnode");
+ response = await addArticle(article);
+ console.log("addArticle result:", response.status);
+ }
+
+ const json = await response.json();
+ console.log("result payload", JSON.stringify(json, null, 2));
+
+ // Get new hashnode url and update
+ if (response.status >= 200 && response.status <= 299) {
+ const hashnode = json?.data?.publishPost?.post?.slug;
+ if (hashnode && !podcast?.hashnode) {
+ console.log("Article Added to hashnode", JSON.stringify(json, null, 2));
+ await updateSanity(podcast._id, hashnode);
+ console.log("Sanity Updated", podcast._id, hashnode);
+ }
+ }
+ return Response.json({ success: true }, { status: 201 });
+ } catch (error) {
+ console.error(error);
+ return Response.json({ success: false, error }, { status: 500 });
+ }
+};
+
+const unPublishPodcast = async (
+ _type: string,
+ id: string,
+ hashnode: string,
+) => {
+ if (!id) {
+ return Response.json(
+ { success: false, error: "Podcast not found" },
+ { status: 404 },
+ );
+ }
+
+ console.log("Unpublishing", { _type, id, hashnode });
+
+ try {
+ if (!hashnode) {
+ return Response.json(
+ { success: false, error: "hashnode not found" },
+ { status: 404 },
+ );
+ }
+ const response = await unpublishArticle(hashnode);
+
+ // Remove hashnode from sanity
+ if (response.status >= 200 && response.status <= 299) {
+ const json = await response.json();
+ console.log("hashnode remove response", JSON.stringify(json, null, 2));
+ if (json.error)
+ return Response.json(
+ { success: false, error: JSON.stringify(json, null, 2) },
+ { status: 200 },
+ );
+ }
+ return Response.json({ success: true }, { status: 200 });
+ } catch (error) {
+ console.error(error);
+ return Response.json({ success: false, error }, { status: 500 });
+ }
+};
+
+const addArticle = async (article: any) => {
+ return fetch("https://gql.hashnode.com", {
+ method: "POST",
+ headers: {
+ authorization: process.env.PRIVATE_HASHNODE || "",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ operationName: "publishPost",
+ query: `mutation publishPost($input: PublishPostInput!) {
+ publishPost(
+ input: $input
+ ) {
+ post {
+ id
+ title
+ slug
+ }
+ }
+ }
+ `,
+ variables: {
+ input: {
+ ...article,
+ },
+ },
+ }),
+ });
+};
+
+const getArticle = async (hashnode: any) => {
+ return fetch("https://gql.hashnode.com", {
+ method: "POST",
+ headers: {
+ authorization: process.env.PRIVATE_HASHNODE || "",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ operationName: "Publication",
+ query: `query Publication {
+ publication(host: "hashnode.codingcat.dev") {
+ post(slug: "${hashnode}") {
+ id
+ }
+ }
+ }`,
+ }),
+ });
+};
+
+const unpublishArticle = async (hashnode: string) => {
+ const requestedArticle = await getArticle(hashnode);
+ const json = await requestedArticle.json();
+ return fetch("https://gql.hashnode.com", {
+ method: "POST",
+ headers: {
+ authorization: process.env.PRIVATE_HASHNODE || "",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ operationName: "removePost",
+ query: `mutation removePost($input: RemovePostInput!) {
+ removePost(
+ input: $input
+ ) {
+ post {
+ slug
+ }
+ }
+ }
+ `,
+ variables: {
+ input: {
+ id: json?.data?.publication?.post?.id,
+ },
+ },
+ }),
+ });
+};
+
+const updateArticle = async (hashnode: string, article: any) => {
+ const requestedArticle = await getArticle(hashnode);
+ const json = await requestedArticle.json();
+
+ //slug cannot be set again
+ const update = article;
+ delete update.slug;
+
+ return fetch("https://gql.hashnode.com", {
+ method: "POST",
+ headers: {
+ authorization: process.env.PRIVATE_HASHNODE || "",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ operationName: "updatePost",
+ query: `mutation updatePost($input: UpdatePostInput!) {
+ updatePost(
+ input: $input
+ ) {
+ post {
+ slug
+ }
+ }
+ }
+ `,
+ variables: {
+ input: {
+ ...json?.data?.publication?.post,
+ ...update,
+ },
+ },
+ }),
+ });
+};
+
+const updateSanity = async (_id: string, hashnode: string) => {
+ return sanityWriteClient.patch(_id).set({ hashnode }).commit();
+};
+
+const sanityQuery = async (_type: string, slug: string) => {
+ const query = _type === "podcast" ? podcastQuery : postQuery;
+ const [podcast] = await Promise.all([
+ sanityWriteClient.fetch(
+ query,
+ {
+ slug,
+ },
+ {
+ stega: false,
+ perspective: "raw",
+ useCdn: false,
+ },
+ ),
+ ]);
+ return podcast;
+};
+
+// Check base.ts for the custom types
+const serializers = {
+ types: {
+ code: (props: any) =>
+ "```" + props?.node?.language + "\n" + props?.node?.code + "\n```",
+ image: (props: any) => {
+ const url = props?.node?.asset?._ref
+ ? urlForImage(props.node)?.url()
+ : "";
+ return ``;
+ },
+ codepen: (props: any) => `{% codepen ${props?.node?.url} %}`,
+ codesandbox: (props: any) =>
+ `{% codesandbox ${props?.node?.url?.split("https://codesandbox.io/p/sandbox/")?.at(-1)} %}`,
+ twitter: (props: any) => `{% twitter ${props?.node?.id} %}`,
+ quote: (props: any) =>
+ `> ${toMarkdown(props?.node?.content, { serializers })}`,
+ },
+};
diff --git a/apps/web/app/api/sponsor/opt-out/route.ts b/apps/web/app/api/sponsor/opt-out/route.ts
new file mode 100644
index 000000000..9234b0e6d
--- /dev/null
+++ b/apps/web/app/api/sponsor/opt-out/route.ts
@@ -0,0 +1,89 @@
+import { sanityWriteClient } from '@/lib/sanity-write-client'
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const token = searchParams.get('token')
+
+ if (!token) {
+ return new Response(
+ renderHtml('Invalid Request', 'No opt-out token provided.'),
+ { status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
+ )
+ }
+
+ try {
+ // Query Sanity for sponsorPool entry with matching optOutToken
+ const query = '*[_type == "sponsorPool" && optOutToken == $token][0]{ _id, companyName }'
+ const params = { token } as Record
+ const sponsor = await sanityWriteClient.fetch(
+ query,
+ params
+ ) as { _id: string; companyName: string } | null
+
+ if (!sponsor) {
+ console.warn('[SPONSOR] Opt-out: invalid token:', token)
+ return new Response(
+ renderHtml('Not Found', 'This opt-out link is invalid or has already been processed.'),
+ { status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
+ )
+ }
+
+ // Set optedOut = true
+ await sanityWriteClient.patch(sponsor._id).set({ optedOut: true }).commit()
+
+ console.log('[SPONSOR] Opt-out processed for:', sponsor.companyName)
+
+ return new Response(
+ renderHtml(
+ 'Opted Out Successfully',
+ `You have been successfully removed from CodingCat.dev sponsor outreach. You will no longer receive emails from us regarding sponsorship opportunities.`
+ ),
+ { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
+ )
+ } catch (error) {
+ console.error('[SPONSOR] Opt-out error:', error)
+ return new Response(
+ renderHtml('Error', 'Something went wrong processing your opt-out. Please try again later.'),
+ { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
+ )
+ }
+}
+
+function renderHtml(title: string, message: string): string {
+ return `
+
+
+
+
+ ${title} — CodingCat.dev
+
+
+
+
+
${title}
+
${message}
+
+
+`
+}
diff --git a/apps/web/app/api/sponsorship/route.ts b/apps/web/app/api/sponsorship/route.ts
new file mode 100644
index 000000000..c66e9a094
--- /dev/null
+++ b/apps/web/app/api/sponsorship/route.ts
@@ -0,0 +1,189 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { apiVersion, dataset, projectId } from "@/sanity/lib/api";
+import { createClient } from "next-sanity";
+import { Resend } from "resend";
+import { EmailTemplate } from "./sponsorship-template";
+import { formSchema } from "@/lib/sponsorship-schema";
+import { render } from "@react-email/render";
+import { extractSponsorIntent } from "@/lib/sponsor/gemini-intent";
+import { sanityWriteClient as pipelineClient } from "@/lib/sanity-write-client";
+
+const sanityWriteClient = createClient({
+ projectId,
+ dataset,
+ apiVersion,
+ token: process.env.SANITY_API_WRITE_TOKEN,
+ perspective: "published",
+ useCdn: false,
+});
+
+const rateLimitStore: Record = {};
+
+const RATE_LIMIT_COUNT = 2; // 2 requests
+const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
+
+export async function POST(request: Request) {
+ console.log("Received sponsorship request");
+ const body = await request.json();
+ console.log("Request body:", body);
+
+ try {
+ const {
+ fullName,
+ email,
+ companyName,
+ sponsorshipTier,
+ message,
+ honeypot,
+ "cf-turnstile-response": turnstileToken,
+ } = formSchema.parse(body);
+
+ // Honeypot check
+ if (honeypot) {
+ console.warn("Honeypot triggered");
+ return NextResponse.json({ message: "Spam detected" }, { status: 400 });
+ }
+
+ const ip =
+ request.headers.get("x-forwarded-for") ||
+ request.headers.get("CF-Connecting-IP") ||
+ "127.0.0.1";
+ console.log("Client IP:", ip);
+
+ const now = Date.now();
+ const userEntry = rateLimitStore[ip];
+ console.log("Rate limit store:", rateLimitStore);
+
+ if (userEntry && now - userEntry.timestamp < RATE_LIMIT_WINDOW) {
+ if (userEntry.count >= RATE_LIMIT_COUNT) {
+ console.warn("Rate limit exceeded for IP:", ip);
+ return NextResponse.json(
+ { message: "Too many requests" },
+ { status: 429 },
+ );
+ }
+ userEntry.count++;
+ } else {
+ rateLimitStore[ip] = { count: 1, timestamp: now };
+ }
+
+ console.log("Verifying Turnstile token");
+ const turnstileResponse = await fetch(
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ secret: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY,
+ response: turnstileToken,
+ remoteip: ip,
+ }),
+ },
+ );
+ const turnstileData = await turnstileResponse.json();
+ console.log("Turnstile response:", turnstileData);
+ if (!turnstileData.success) {
+ console.warn("Invalid CAPTCHA", turnstileData["error-codes"]);
+ return NextResponse.json(
+ { message: "Invalid CAPTCHA", details: turnstileData["error-codes"] },
+ { status: 400 },
+ );
+ }
+
+ const sponsorshipRequest = {
+ _type: "sponsorshipRequest",
+ fullName,
+ email,
+ companyName,
+ sponsorshipTier,
+ message,
+ };
+
+ try {
+ console.log("Creating Sanity document:", sponsorshipRequest);
+ const sanityResponse = await sanityWriteClient.create(sponsorshipRequest);
+ console.log("Sanity response:", sanityResponse);
+ } catch (error) {
+ console.error("Failed to save sponsorship request:", error);
+ return NextResponse.json(
+ { message: "Failed to save sponsorship request", details: error },
+ { status: 500 },
+ );
+ }
+
+ // Also create a sponsorLead for the automated pipeline
+ try {
+ const intent = await extractSponsorIntent(
+ `Company: ${companyName || "Unknown"}\nFrom: ${fullName} (${email})\nTiers: ${sponsorshipTier.join(", ")}\n${message || ""}`,
+ );
+
+ await pipelineClient.create({
+ _type: "sponsorLead",
+ companyName: intent.companyName || companyName || "Unknown",
+ contactName: intent.contactName || fullName,
+ contactEmail: email,
+ source: "inbound",
+ status: "new",
+ intent: intent.intent,
+ rateCard: sponsorshipTier.join(", "),
+ threadId: crypto.randomUUID(),
+ lastEmailAt: new Date().toISOString(),
+ });
+ console.log("[SPONSOR] Created sponsorLead from form submission");
+ } catch (error) {
+ // Don't fail the form submission if pipeline creation fails
+ console.error("[SPONSOR] Failed to create sponsorLead from form:", error);
+ }
+
+ try {
+ const resendApiKey = process.env.RESEND_SPONSORSHIP_API_KEY;
+ if (resendApiKey) {
+ console.log("Sending email with Resend");
+ const resend = new Resend(resendApiKey);
+ const { data, error } = await resend.emails.send({
+ from: "Sponsorships ",
+ to: ["alex@codingcat.dev"],
+ subject: "New Sponsorship Request",
+ html: await render(
+ EmailTemplate({
+ fullName,
+ email,
+ companyName,
+ sponsorshipTier,
+ message,
+ }),
+ ),
+ });
+ console.log("Resend response:", { data, error });
+ if (error) {
+ console.error("Failed to send email:", error);
+ return NextResponse.json({ message: error.message }, { status: 400 });
+ }
+ } else {
+ console.warn("RESEND_SPONSORSHIP_API_KEY not set — skipping email");
+ }
+ } catch (error) {
+ console.error("Error sending email with Resend:", error);
+ return NextResponse.json(
+ { message: "Failed to send email" },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json({
+ message: "Sponsorship request submitted successfully",
+ });
+ } catch (error) {
+ console.error("Error processing sponsorship request:", error);
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ message: error.message }, { status: 400 });
+ }
+ return NextResponse.json(
+ { message: "Internal Server Error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/sponsorship/sponsorship-template.tsx b/apps/web/app/api/sponsorship/sponsorship-template.tsx
new file mode 100644
index 000000000..91bac26b1
--- /dev/null
+++ b/apps/web/app/api/sponsorship/sponsorship-template.tsx
@@ -0,0 +1,48 @@
+import * as React from "react";
+
+interface EmailTemplateProps {
+ fullName: string;
+ email: string;
+ companyName?: string;
+ sponsorshipTier: string[];
+ message?: string;
+}
+
+export function EmailTemplate({
+ fullName,
+ email,
+ companyName,
+ sponsorshipTier,
+ message,
+}: EmailTemplateProps) {
+ return (
+
+
New Sponsorship Request
+
+ Full Name: {fullName}
+
+
+ Email: {email}
+
+ {companyName && (
+
+ Company Name: {companyName}
+
+ )}
+
+ Sponsorship Tiers:
+
+
+ {sponsorshipTier.map((tier) => (
+ - {tier}
+ ))}
+
+ {message && (
+
+ Message:
+
+ )}
+ {message &&
{message}
}
+
+ );
+}
diff --git a/apps/web/app/api/verify-turnstile.ts b/apps/web/app/api/verify-turnstile.ts
new file mode 100644
index 000000000..0241e4183
--- /dev/null
+++ b/apps/web/app/api/verify-turnstile.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+
+export async function POST(request: Request) {
+ const { "cf-turnstile-response": turnstileToken } = await request.json();
+
+ const secretKey = process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY;
+
+ if (!secretKey) {
+ return NextResponse.json(
+ { success: false, message: "TURNSTILE_SECRET_KEY is not set" },
+ { status: 500 },
+ );
+ }
+
+ const response = await fetch(
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: `secret=${secretKey}&response=${turnstileToken}`,
+ },
+ );
+
+ const data = await response.json();
+
+ if (data.success) {
+ return NextResponse.json({
+ success: true,
+ message: "Turnstile verification successful",
+ });
+ } else {
+ return NextResponse.json(
+ { success: false, message: "Turnstile verification failed" },
+ { status: 400 },
+ );
+ }
+}
diff --git a/apps/web/app/api/webhooks/sanity-content/route.ts b/apps/web/app/api/webhooks/sanity-content/route.ts
new file mode 100644
index 000000000..1bc69ecd5
--- /dev/null
+++ b/apps/web/app/api/webhooks/sanity-content/route.ts
@@ -0,0 +1,105 @@
+import { NextResponse, after } from 'next/server';
+import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook';
+import { processVideoProduction } from '@/lib/services/video-pipeline';
+
+const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;
+
+interface SanityWebhookBody {
+ _id: string;
+ _type: string;
+ status?: string;
+}
+
+/**
+ * Sanity webhook handler for the video production pipeline.
+ *
+ * Listens for automatedVideo documents transitioning to "script_ready" status
+ * and triggers the video production pipeline in the background.
+ *
+ * Configure in Sanity: Webhook → POST → filter: `_type == "automatedVideo"`
+ * with projection: `{ _id, _type, status }`
+ */
+export async function POST(request: Request) {
+ try {
+ if (!WEBHOOK_SECRET) {
+ console.log('[WEBHOOK] Missing SANITY_WEBHOOK_SECRET environment variable');
+ return NextResponse.json(
+ { error: 'Server misconfigured: missing webhook secret' },
+ { status: 500 }
+ );
+ }
+
+ // Read the raw body as text for signature verification
+ const rawBody = await request.text();
+ const signature = request.headers.get(SIGNATURE_HEADER_NAME);
+
+ if (!signature) {
+ console.log('[WEBHOOK] Missing signature header');
+ return NextResponse.json(
+ { error: 'Missing signature' },
+ { status: 401 }
+ );
+ }
+
+ // Verify the webhook signature
+ const isValid = await isValidSignature(rawBody, signature, WEBHOOK_SECRET);
+
+ if (!isValid) {
+ console.log('[WEBHOOK] Invalid signature received');
+ return NextResponse.json(
+ { error: 'Invalid signature' },
+ { status: 401 }
+ );
+ }
+
+ // Parse the verified body
+ let body: SanityWebhookBody;
+ try {
+ body = JSON.parse(rawBody);
+ } catch {
+ console.log('[WEBHOOK] Failed to parse webhook body');
+ return NextResponse.json(
+ { skipped: true, reason: 'Invalid JSON body' },
+ { status: 200 }
+ );
+ }
+
+ console.log(`[WEBHOOK] Received document: type=${body._type}, id=${body._id}, status=${body.status}`);
+
+ if (body._type !== 'automatedVideo') {
+ console.log(`[WEBHOOK] Skipping: document type is "${body._type}", not "automatedVideo"`);
+ return NextResponse.json(
+ { skipped: true, reason: `Document type "${body._type}" is not "automatedVideo"` },
+ { status: 200 }
+ );
+ }
+
+ if (body.status !== 'script_ready') {
+ console.log(`[WEBHOOK] Skipping: status is "${body.status}", not "script_ready"`);
+ return NextResponse.json(
+ { skipped: true, reason: `Status "${body.status}" is not "script_ready"` },
+ { status: 200 }
+ );
+ }
+
+ // Use after() to run the pipeline after the response is sent.
+ // On Vercel, serverless functions terminate after the response — fire-and-forget
+ // (promise.catch() without await) silently dies. after() keeps the function alive.
+ console.log(`[WEBHOOK] Triggering video production for document: ${body._id}`);
+ after(async () => {
+ try {
+ await processVideoProduction(body._id);
+ } catch (error) {
+ console.error(`[WEBHOOK] Background processing error for ${body._id}:`, error);
+ }
+ });
+
+ return NextResponse.json({ triggered: true }, { status: 200 });
+ } catch (error) {
+ console.log('[WEBHOOK] Unexpected error processing webhook:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/webhooks/sanity-distribute/route.ts b/apps/web/app/api/webhooks/sanity-distribute/route.ts
new file mode 100644
index 000000000..0df1c41c9
--- /dev/null
+++ b/apps/web/app/api/webhooks/sanity-distribute/route.ts
@@ -0,0 +1,402 @@
+import { NextResponse, after } from "next/server";
+import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
+import { writeClient } from "@/lib/sanity-write-client";
+import { generateWithGemini } from "@/lib/gemini";
+import { uploadVideo, uploadShort, generateShortsMetadata } from "@/lib/youtube-upload";
+import { notifySubscribers } from "@/lib/resend-notify";
+import { postVideoAnnouncement } from "@/lib/x-social";
+import { getConfig } from "@/lib/config";
+
+const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface WebhookPayload {
+ _id: string;
+ _type: string;
+ status?: string;
+}
+
+interface AutomatedVideoDoc {
+ _id: string;
+ _type: "automatedVideo";
+ title: string;
+ script: {
+ hook: string;
+ scenes: Array<{
+ sceneNumber: number;
+ narration: string;
+ visualDescription: string;
+ bRollKeywords: string[];
+ durationEstimate: number;
+ }>;
+ cta: string;
+ };
+ status: "draft" | "script_ready" | "audio_gen" | "video_gen" | "flagged" | "uploading" | "published";
+ videoUrl?: string;
+ shortUrl?: string;
+ audioUrl?: string;
+ youtubeId?: string;
+ youtubeShortId?: string;
+ flaggedReason?: string;
+ scheduledPublishAt?: string;
+ distributionLog?: DistributionLogEntry[];
+}
+
+interface DistributionLogEntry {
+ _key: string;
+ step: string;
+ status: "success" | "failed" | "skipped";
+ error?: string;
+ timestamp: string;
+ result?: string;
+}
+
+interface YouTubeMetadata { title: string; description: string; tags: string[]; }
+
+// ---------------------------------------------------------------------------
+// Distribution log helpers
+// ---------------------------------------------------------------------------
+
+function logEntry(step: string, status: "success" | "failed" | "skipped", opts?: { error?: string; result?: string }): DistributionLogEntry {
+ return {
+ _key: `${step}-${Date.now()}`,
+ step,
+ status,
+ error: opts?.error,
+ timestamp: new Date().toISOString(),
+ result: opts?.result,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Gemini metadata generation for long-form videos
+// ---------------------------------------------------------------------------
+
+async function generateYouTubeMetadata(doc: AutomatedVideoDoc): Promise {
+ const scriptText = doc.script
+ ? [doc.script.hook, ...(doc.script.scenes?.map((s) => s.narration) ?? []), doc.script.cta].filter(Boolean).join("\n\n")
+ : "";
+
+ const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.
+
+Video Title: ${doc.title}
+Script: ${scriptText}
+
+Generate optimized YouTube metadata for a LONG-FORM video (not Shorts).
+
+Return JSON:
+{
+ "title": "SEO-optimized title, max 100 chars, engaging but not clickbait",
+ "description": "500-1000 chars with key points, timestamps placeholder, channel links, and hashtags",
+ "tags": ["10-15 relevant tags for discoverability"]
+}
+
+Include in the description:
+- Brief summary of what viewers will learn
+- Key topics covered
+- Links section placeholder
+- Relevant hashtags at the end`;
+
+ const raw = await generateWithGemini(prompt);
+ try {
+ const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim()) as YouTubeMetadata;
+ return {
+ title: parsed.title?.slice(0, 100) || doc.title,
+ description: parsed.description || doc.title,
+ tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [],
+ };
+ } catch {
+ return { title: doc.title, description: doc.title, tags: [] };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sanity helpers
+// ---------------------------------------------------------------------------
+
+async function updateStatus(docId: string, status: string, extra: Record = {}): Promise {
+ await writeClient.patch(docId).set({ status, ...extra }).commit();
+ console.log(`[sanity-distribute] ${docId} -> ${status}`);
+}
+
+async function appendDistributionLog(docId: string, entries: DistributionLogEntry[]): Promise {
+ const ops = entries.map((entry) => ({
+ insert: { after: "distributionLog[-1]", items: [entry] },
+ }));
+ // Use setIfMissing to create the array if it doesn't exist, then append
+ let patch = writeClient.patch(docId).setIfMissing({ distributionLog: [] });
+ for (const entry of entries) {
+ patch = patch.append("distributionLog", [entry]);
+ }
+ await patch.commit();
+}
+
+// ---------------------------------------------------------------------------
+// Core distribution pipeline (runs inside after())
+// ---------------------------------------------------------------------------
+
+async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise {
+ const log: DistributionLogEntry[] = [];
+
+ // Fetch distribution config from Sanity singleton
+ const distConfig = await getConfig("distribution_config");
+
+ try {
+ await updateStatus(docId, "uploading");
+
+ // Step 1: Generate long-form YouTube metadata via Gemini
+ console.log("[sanity-distribute] Step 1/6 - Generating long-form metadata");
+ let metadata: YouTubeMetadata;
+ try {
+ metadata = await generateYouTubeMetadata(doc);
+ log.push(logEntry("gemini-metadata", "success"));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error("[sanity-distribute] Gemini metadata failed:", msg);
+ log.push(logEntry("gemini-metadata", "failed", { error: msg }));
+ // Fallback metadata so we can still upload
+ metadata = { title: doc.title, description: doc.title, tags: [] };
+ }
+
+ // Step 2: Upload main video to YouTube
+ let youtubeVideoId = "";
+ if (doc.videoUrl) {
+ console.log("[sanity-distribute] Step 2/6 - Uploading main video");
+ try {
+ const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
+ youtubeVideoId = r.videoId;
+ log.push(logEntry("youtube-upload", "success", { result: youtubeVideoId }));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error("[sanity-distribute] YouTube upload failed:", msg);
+ log.push(logEntry("youtube-upload", "failed", { error: msg }));
+ }
+ } else {
+ log.push(logEntry("youtube-upload", "skipped", { error: "No videoUrl" }));
+ }
+
+ // Step 3: Generate Shorts-optimized metadata + upload Short
+ let youtubeShortId = "";
+ if (doc.shortUrl) {
+ console.log("[sanity-distribute] Step 3/6 - Generating Shorts metadata + uploading");
+ try {
+ const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc);
+ const r = await uploadShort({
+ videoUrl: doc.shortUrl,
+ title: shortsMetadata.title,
+ description: shortsMetadata.description,
+ tags: shortsMetadata.tags,
+ });
+ youtubeShortId = r.videoId;
+ log.push(logEntry("youtube-short", "success", { result: youtubeShortId }));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error("[sanity-distribute] Short upload failed:", msg);
+ log.push(logEntry("youtube-short", "failed", { error: msg }));
+ }
+ } else {
+ log.push(logEntry("youtube-short", "skipped", { error: "No shortUrl" }));
+ }
+
+ // Step 4: Email notification (non-fatal) — uses distribution config
+ console.log("[sanity-distribute] Step 4/6 - Sending email");
+ const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${youtubeVideoId}` : doc.videoUrl || "";
+ try {
+ await notifySubscribers({
+ subject: `New Video: ${metadata.title}`,
+ videoTitle: metadata.title,
+ videoUrl: ytUrl,
+ description: metadata.description.slice(0, 280),
+ fromEmail: distConfig.resendFromEmail,
+ notificationEmails: distConfig.notificationEmails,
+ });
+ log.push(logEntry("email", "success"));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.warn("[sanity-distribute] Email error:", msg);
+ log.push(logEntry("email", "failed", { error: msg }));
+ }
+
+ // Step 5: Post to X/Twitter (non-fatal)
+ console.log("[sanity-distribute] Step 5/6 - Posting to X/Twitter");
+ try {
+ const tweetResult = await postVideoAnnouncement({
+ videoTitle: metadata.title,
+ youtubeUrl: ytUrl,
+ tags: metadata.tags,
+ });
+ if (tweetResult.success) {
+ log.push(logEntry("x-twitter", "success", { result: tweetResult.tweetId }));
+ } else {
+ log.push(logEntry("x-twitter", "failed", { error: tweetResult.error }));
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.warn("[sanity-distribute] X/Twitter error:", msg);
+ log.push(logEntry("x-twitter", "failed", { error: msg }));
+ }
+
+ // Step 6: Mark published in Sanity + save distribution log
+ console.log("[sanity-distribute] Step 6/6 - Marking published");
+ await writeClient
+ .patch(docId)
+ .set({
+ status: "published",
+ youtubeId: youtubeVideoId || undefined,
+ youtubeShortId: youtubeShortId || undefined,
+ })
+ .setIfMissing({ distributionLog: [] })
+ .append("distributionLog", log)
+ .commit();
+
+ console.log(`[sanity-distribute] ✅ Distribution complete for ${docId}`);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`[sanity-distribute] ❌ Failed ${docId}: ${msg}`);
+ log.push(logEntry("pipeline", "failed", { error: msg }));
+
+ try {
+ await writeClient
+ .patch(docId)
+ .set({
+ status: "flagged",
+ flaggedReason: `Distribution error: ${msg}`,
+ })
+ .setIfMissing({ distributionLog: [] })
+ .append("distributionLog", log)
+ .commit();
+ } catch {
+ // Last resort — at least try to save the log
+ console.error("[sanity-distribute] Failed to save error state");
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// POST handler
+// ---------------------------------------------------------------------------
+
+/**
+ * Sanity webhook handler for the distribution pipeline.
+ *
+ * Listens for automatedVideo documents transitioning to "video_gen" status
+ * and triggers YouTube upload, email notification, and social posting.
+ *
+ * Uses after() to return 200 immediately and run the heavy pipeline work
+ * in the background — prevents Vercel from killing the function mid-upload.
+ *
+ * Configure in Sanity: Webhook → POST → filter: `_type == "automatedVideo"`
+ * with projection: `{ _id, _type, status }`
+ */
+export async function POST(request: Request) {
+ try {
+ if (!WEBHOOK_SECRET) {
+ console.log("[sanity-distribute] Missing SANITY_WEBHOOK_SECRET environment variable");
+ return NextResponse.json(
+ { error: "Server misconfigured: missing webhook secret" },
+ { status: 500 }
+ );
+ }
+
+ // Read the raw body as text for signature verification
+ const rawBody = await request.text();
+ const signature = request.headers.get(SIGNATURE_HEADER_NAME);
+
+ if (!signature) {
+ console.log("[sanity-distribute] Missing signature header");
+ return NextResponse.json(
+ { error: "Missing signature" },
+ { status: 401 }
+ );
+ }
+
+ // Verify the webhook signature (same as sanity-content route)
+ const isValid = await isValidSignature(rawBody, signature, WEBHOOK_SECRET);
+
+ if (!isValid) {
+ console.log("[sanity-distribute] Invalid signature received");
+ return NextResponse.json(
+ { error: "Invalid signature" },
+ { status: 401 }
+ );
+ }
+
+ // Parse the verified body
+ let webhookPayload: WebhookPayload;
+ try {
+ webhookPayload = JSON.parse(rawBody);
+ } catch {
+ console.log("[sanity-distribute] Failed to parse webhook body");
+ return NextResponse.json(
+ { skipped: true, reason: "Invalid JSON body" },
+ { status: 200 }
+ );
+ }
+
+ console.log(`[sanity-distribute] Received: type=${webhookPayload._type}, id=${webhookPayload._id}, status=${webhookPayload.status}`);
+
+ if (webhookPayload._type !== "automatedVideo") {
+ return NextResponse.json(
+ { skipped: true, reason: `Not automatedVideo` },
+ { status: 200 }
+ );
+ }
+
+ if (webhookPayload.status !== "video_gen") {
+ return NextResponse.json(
+ { skipped: true, reason: `Status "${webhookPayload.status}" is not "video_gen"` },
+ { status: 200 }
+ );
+ }
+
+ const docId = webhookPayload._id;
+
+ // Fetch the full document from Sanity (webhook only sends minimal projection)
+ const doc = await writeClient.fetch(
+ `*[_id == $id][0]`,
+ { id: docId }
+ );
+
+ if (!doc) {
+ console.error(`[sanity-distribute] Document ${docId} not found`);
+ return NextResponse.json({ error: "Document not found" }, { status: 404 });
+ }
+
+ // Re-check status from the actual document (race condition guard)
+ if (doc.status !== "video_gen") {
+ return NextResponse.json(
+ { skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` },
+ { status: 200 }
+ );
+ }
+ if (doc.flaggedReason) {
+ return NextResponse.json(
+ { skipped: true, reason: "Flagged" },
+ { status: 200 }
+ );
+ }
+
+ // Use after() to run the distribution pipeline after the response is sent.
+ // On Vercel, serverless functions terminate after the response — fire-and-forget
+ // (promise.catch() without await) silently dies. after() keeps the function alive.
+ console.log(`[sanity-distribute] Triggering distribution for: ${docId}`);
+ after(async () => {
+ try {
+ await runDistribution(docId, doc);
+ } catch (error) {
+ console.error(`[sanity-distribute] Background processing error for ${docId}:`, error);
+ }
+ });
+
+ return NextResponse.json({ triggered: true, docId }, { status: 200 });
+ } catch (error) {
+ console.log("[sanity-distribute] Unexpected error processing webhook:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/api/webhooks/sanity-revalidate/route.ts b/apps/web/app/api/webhooks/sanity-revalidate/route.ts
new file mode 100644
index 000000000..f6563d508
--- /dev/null
+++ b/apps/web/app/api/webhooks/sanity-revalidate/route.ts
@@ -0,0 +1,67 @@
+import { revalidateTag } from "next/cache";
+import { type NextRequest, NextResponse } from "next/server";
+import { parseBody } from "next-sanity/webhook";
+
+export async function POST(request: NextRequest) {
+ try {
+ const { isValidSignature, body } = await parseBody<{
+ _type: string;
+ slug?: string;
+ }>(request, process.env.SANITY_WEBHOOK_SECRET);
+
+ if (!isValidSignature) {
+ return NextResponse.json(
+ { message: "Invalid signature" },
+ { status: 401 },
+ );
+ }
+
+ if (!body?._type) {
+ return NextResponse.json(
+ { message: "Bad request" },
+ { status: 400 },
+ );
+ }
+
+ // With defineLive + , content pages get real-time updates
+ // without ISR revalidation. We only keep revalidateTag("sanity") as a
+ // fallback for when no active visitors are triggering live updates.
+ //
+ // IMPORTANT: Skip pipeline/internal document types that don't have
+ // public-facing pages. These fire frequently (every cron run) and
+ // were the primary cause of ISR write exhaustion.
+ const SKIP_TYPES = new Set([
+ "automatedVideo",
+ "contentIdea",
+ "sponsorLead",
+ "pipeline_config",
+ "content_config",
+ ]);
+
+ if (SKIP_TYPES.has(body._type)) {
+ return NextResponse.json({
+ skipped: true,
+ type: body._type,
+ reason: "Internal document type — no revalidation needed",
+ });
+ }
+
+ // For public content types, revalidate the sanity tag as a fallback.
+ // This only affects pages that still use Next.js cache tags (e.g., sitemap).
+ // Next.js 16 requires a second argument — { expire: 0 } for immediate invalidation
+ revalidateTag("sanity", { expire: 0 });
+
+ return NextResponse.json({
+ revalidated: true,
+ type: body._type,
+ slug: body.slug,
+ now: Date.now(),
+ });
+ } catch (error) {
+ console.error("Revalidation error:", error);
+ return NextResponse.json(
+ { message: "Error revalidating" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/webhooks/sponsor-inbound/route.ts b/apps/web/app/api/webhooks/sponsor-inbound/route.ts
new file mode 100644
index 000000000..61a56a58f
--- /dev/null
+++ b/apps/web/app/api/webhooks/sponsor-inbound/route.ts
@@ -0,0 +1,98 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { sanityWriteClient } from '@/lib/sanity-write-client'
+import { extractSponsorIntent } from '@/lib/sponsor/gemini-intent'
+import { sendSponsorEmail } from '@/lib/sponsor/email-service'
+
+const RATE_CARD = `
+CodingCat.dev Sponsorship Tiers:
+- Dedicated Video ($4,000) — Full dedicated video about your product
+- Integrated Mid-Roll Ad ($1,800) — Mid-roll advertisement in our videos
+- Quick Shout-Out ($900) — Brief mention in our videos
+- Blog Post / Newsletter ($500) — Featured in our blog or newsletter
+- Video Series (Custom) — Multi-video partnership series
+
+Learn more: https://codingcat.dev/sponsorships
+`.trim()
+
+const inboundSchema = z.object({
+ fullName: z.string().min(1, 'Full name is required'),
+ email: z.string().email('Valid email is required'),
+ company: z.string().optional().default(''),
+ message: z.string().optional().default(''),
+ tiers: z.array(z.string()).optional().default([]),
+})
+
+export async function POST(request: Request) {
+ // Verify webhook secret
+ const webhookSecret = request.headers.get('x-webhook-secret')
+ if (!webhookSecret || webhookSecret !== process.env.SPONSOR_WEBHOOK_SECRET) {
+ console.error('[SPONSOR] Inbound webhook: unauthorized request')
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const body = await request.json()
+ const parsed = inboundSchema.safeParse(body)
+
+ if (!parsed.success) {
+ console.error('[SPONSOR] Inbound webhook: validation failed', parsed.error.issues)
+ return NextResponse.json(
+ { error: 'Validation failed', details: parsed.error.issues },
+ { status: 400 }
+ )
+ }
+
+ const { fullName, email, company, message, tiers } = parsed.data
+
+ // Extract intent using Gemini
+ const combinedMessage = [
+ company ? `Company: ${company}` : '',
+ `From: ${fullName} (${email})`,
+ tiers.length ? `Interested tiers: ${tiers.join(', ')}` : '',
+ message,
+ ]
+ .filter(Boolean)
+ .join('\n')
+
+ console.log('[SPONSOR] Processing inbound inquiry from:', email)
+
+ const intent = await extractSponsorIntent(combinedMessage)
+
+ // Create sponsorLead in Sanity
+ const leadDoc = {
+ _type: 'sponsorLead',
+ companyName: intent.companyName || company || 'Unknown',
+ contactName: intent.contactName || fullName,
+ contactEmail: email,
+ source: 'inbound',
+ status: 'new',
+ intent: intent.intent,
+ rateCard: tiers.length > 0 ? tiers.join(', ') : intent.suggestedTiers.join(', '),
+ threadId: crypto.randomUUID(),
+ lastEmailAt: new Date().toISOString(),
+ }
+
+ const created = await sanityWriteClient.create(leadDoc)
+ console.log('[SPONSOR] Created sponsor lead:', created._id)
+
+ // Send auto-reply with rate card (stubbed)
+ await sendSponsorEmail(
+ email,
+ `Thanks for your interest in sponsoring CodingCat.dev!`,
+ `Hi ${fullName},\n\nThanks for reaching out about sponsoring CodingCat.dev! I'm excited to explore how we can work together.\n\nHere's our current rate card:\n\n${RATE_CARD}\n\nI'll review your inquiry and get back to you within 48 hours.\n\nBest,\nAlex Patterson\nCodingCat.dev`
+ )
+
+ return NextResponse.json({
+ success: true,
+ leadId: created._id,
+ message: 'Sponsor inquiry received and processed',
+ })
+ } catch (error) {
+ console.error('[SPONSOR] Inbound webhook error:', error)
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/web/app/api/webhooks/stripe-sponsor/route.ts b/apps/web/app/api/webhooks/stripe-sponsor/route.ts
new file mode 100644
index 000000000..cc4e0380a
--- /dev/null
+++ b/apps/web/app/api/webhooks/stripe-sponsor/route.ts
@@ -0,0 +1,172 @@
+import { NextResponse } from 'next/server'
+import Stripe from 'stripe'
+import { sanityWriteClient } from '@/lib/sanity-write-client'
+import { bridgeSponsorLeadToSponsor } from '@/lib/sponsor/sponsor-bridge'
+
+/**
+ * Stripe webhook handler for sponsor invoices.
+ *
+ * Handles:
+ * - invoice.paid → update sponsorLead status to 'paid', assign to next video
+ * - invoice.payment_failed → update sponsorLead status back to 'negotiating'
+ */
+
+/** Lazy Stripe client — only created when actually needed */
+let _stripe: Stripe | null = null
+function getStripeClient(): Stripe {
+ if (!_stripe) {
+ if (!process.env.STRIPE_SECRET_KEY) {
+ throw new Error('STRIPE_SECRET_KEY not set')
+ }
+ _stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
+ }
+ return _stripe
+}
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.text()
+ const sig = request.headers.get('stripe-signature')
+
+ if (!sig) {
+ console.error('[SPONSOR] Missing stripe-signature header')
+ return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
+ }
+
+ if (!process.env.STRIPE_WEBHOOK_SECRET) {
+ console.error('[SPONSOR] STRIPE_WEBHOOK_SECRET not set')
+ return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
+ }
+
+ // Verify webhook signature
+ const stripe = getStripeClient()
+ let event: Stripe.Event
+ try {
+ event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET)
+ } catch (err) {
+ console.error('[SPONSOR] Webhook signature verification failed:', err)
+ return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
+ }
+
+ console.log('[SPONSOR] Stripe webhook received:', {
+ type: event.type,
+ id: event.id,
+ timestamp: new Date().toISOString(),
+ })
+
+ switch (event.type) {
+ case 'invoice.paid': {
+ const invoice = event.data.object as Stripe.Invoice
+ console.log('[SPONSOR] Invoice paid:', invoice.id)
+
+ // Find the sponsorLead by stripeInvoiceId in Sanity
+ const lead = await sanityWriteClient.fetch(
+ `*[_type == "sponsorLead" && stripeInvoiceId == $invoiceId][0]`,
+ { invoiceId: invoice.id }
+ )
+
+ if (!lead) {
+ console.warn('[SPONSOR] No sponsorLead found for invoice:', invoice.id)
+ break
+ }
+
+ // Idempotency guard — skip if already paid (Stripe retries on 5xx)
+ if (lead.status === 'paid') {
+ console.log('[SPONSOR] Lead already paid, skipping (idempotent):', lead._id)
+ break
+ }
+
+ // Update status to 'paid'
+ await sanityWriteClient
+ .patch(lead._id)
+ .set({
+ status: 'paid',
+ stripePaymentStatus: 'paid',
+ })
+ .commit()
+
+ console.log('[SPONSOR] Updated sponsorLead to paid:', lead._id)
+
+ // Bridge: create/link sponsor doc for content attribution
+ try {
+ await bridgeSponsorLeadToSponsor(lead._id)
+ console.log('[SPONSOR] Sponsor bridge completed for lead:', lead._id)
+ } catch (bridgeError) {
+ // Non-fatal — don't fail the webhook if bridge fails
+ console.error('[SPONSOR] Sponsor bridge failed (non-fatal):', bridgeError)
+ }
+
+ // Find next available automatedVideo (status script_ready or later, no sponsorSlot assigned)
+ const availableVideo = await sanityWriteClient.fetch(
+ `*[_type == "automatedVideo" && status in ["script_ready", "media_ready", "ready_to_publish"] && !defined(bookedSlot)] | order(_createdAt asc) [0]{
+ _id,
+ title,
+ status
+ }`
+ )
+
+ if (availableVideo) {
+ // Assign the lead to the video via bookedSlot
+ await sanityWriteClient
+ .patch(availableVideo._id)
+ .set({
+ bookedSlot: {
+ _type: 'reference',
+ _ref: lead._id,
+ },
+ })
+ .commit()
+
+ console.log('[SPONSOR] Assigned lead to video:', {
+ leadId: lead._id,
+ videoId: availableVideo._id,
+ videoTitle: availableVideo.title,
+ })
+ } else {
+ console.warn('[SPONSOR] No available video found for lead:', lead._id)
+ }
+
+ break
+ }
+
+ case 'invoice.payment_failed': {
+ const invoice = event.data.object as Stripe.Invoice
+ console.log('[SPONSOR] Invoice payment failed:', invoice.id)
+
+ // Find the sponsorLead by stripeInvoiceId in Sanity
+ const lead = await sanityWriteClient.fetch(
+ `*[_type == "sponsorLead" && stripeInvoiceId == $invoiceId][0]`,
+ { invoiceId: invoice.id }
+ )
+
+ if (!lead) {
+ console.warn('[SPONSOR] No sponsorLead found for failed invoice:', invoice.id)
+ break
+ }
+
+ // Update sponsorLead status back to 'negotiating'
+ await sanityWriteClient
+ .patch(lead._id)
+ .set({
+ status: 'negotiating',
+ stripePaymentStatus: 'failed',
+ })
+ .commit()
+
+ console.log('[SPONSOR] Updated sponsorLead to negotiating (payment failed):', lead._id)
+ break
+ }
+
+ default:
+ console.log('[SPONSOR] Unhandled webhook event type:', event.type)
+ }
+
+ return NextResponse.json({ received: true })
+ } catch (error) {
+ console.error('[SPONSOR] Stripe webhook error:', error)
+ return NextResponse.json(
+ { error: 'Webhook processing failed' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/web/app/api/youtube/rss.xml/route.tsx b/apps/web/app/api/youtube/rss.xml/route.tsx
new file mode 100644
index 000000000..7bd99ce91
--- /dev/null
+++ b/apps/web/app/api/youtube/rss.xml/route.tsx
@@ -0,0 +1,109 @@
+import { connection } from "next/server";
+import { Feed } from "feed";
+
+const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY as string;
+const CHANNEL_ID = process.env.YOUTUBE_CHANNEL_ID as string;
+
+interface PlaylistItem {
+ id: string;
+ snippet: {
+ title: string;
+ description: string;
+ };
+}
+
+interface PlaylistItemsResponse {
+ items: {
+ snippet: {
+ title: string;
+ description: string;
+ resourceId: {
+ videoId: string;
+ };
+ publishedAt: string;
+ };
+ }[];
+ nextPageToken?: string;
+}
+
+async function fetchPodcastPlaylists(
+ channelId: string,
+): Promise {
+ const url = `https://www.googleapis.com/youtube/v3/playlists?key=${YOUTUBE_API_KEY}&channelId=${channelId}&part=snippet&maxResults=50`;
+ const response = await fetch(url);
+ const data = await response.json();
+ return data.items.filter(
+ (playlist: PlaylistItem) =>
+ playlist.snippet.title.toLowerCase().includes("podcast") ||
+ playlist.snippet.description.toLowerCase().includes("podcast"),
+ );
+}
+
+async function fetchPlaylistItems(
+ playlistId: string,
+ pageToken = "",
+): Promise {
+ const url = `https://www.googleapis.com/youtube/v3/playlistItems?key=${YOUTUBE_API_KEY}&playlistId=${playlistId}&part=snippet&maxResults=50${pageToken ? `&pageToken=${pageToken}` : ""}`;
+ const response = await fetch(url);
+ return response.json();
+}
+
+export async function GET() {
+ // Hits the external YouTube API and uses the current time, so this must be
+ // generated per-request rather than prerendered at build under Cache Components.
+ await connection();
+
+ try {
+ const feed = new Feed({
+ title: "YouTube Channel Podcast Feed",
+ description: "Latest podcast episodes from the YouTube channel",
+ id: `https://www.youtube.com/channel/${CHANNEL_ID}`,
+ link: `https://www.youtube.com/channel/${CHANNEL_ID}`,
+ language: "en",
+ image: "https://www.youtube.com/img/desktop/yt_1200.png",
+ favicon: "https://www.youtube.com/favicon.ico",
+ copyright: `All rights reserved ${new Date().getFullYear()}, YouTube`,
+ updated: new Date(),
+ generator: "Next.js using Feed for Node.js",
+ feedLinks: {
+ rss2: `${process.env.NEXT_PUBLIC_BASE_URL || "https://codingcat.dev"}/api/youtube/rss.xml`,
+ },
+ });
+
+ const podcastPlaylists = await fetchPodcastPlaylists(CHANNEL_ID);
+
+ for (const playlist of podcastPlaylists) {
+ let pageToken: string | undefined = "";
+ do {
+ const data = await fetchPlaylistItems(playlist.id, pageToken);
+
+ data.items.forEach((item) => {
+ feed.addItem({
+ title: item.snippet.title,
+ content: item.snippet.description || "",
+ link: `https://www.youtube.com/watch?v=${item.snippet.resourceId.videoId}`,
+ description: item.snippet.description,
+ image: `https://img.youtube.com/vi/${item.snippet.resourceId.videoId}/maxresdefault.jpg`,
+ date: new Date(item.snippet.publishedAt),
+ id: item.snippet.resourceId.videoId,
+ });
+ });
+
+ pageToken = data.nextPageToken;
+ } while (pageToken);
+ }
+
+ return new Response(feed.rss2(), {
+ headers: {
+ "content-type": "application/rss+xml; charset=utf-8",
+ "cache-control": "max-age=0, s-maxage=3600",
+ },
+ });
+ } catch (error) {
+ console.error("Error generating podcast feed:", error);
+ return Response.json(
+ { error: "Error generating podcast feed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/app/api/youtube/views/route.tsx b/apps/web/app/api/youtube/views/route.tsx
new file mode 100644
index 000000000..42294e802
--- /dev/null
+++ b/apps/web/app/api/youtube/views/route.tsx
@@ -0,0 +1,51 @@
+import type { NextRequest } from "next/server";
+import {
+ syncSanityVideosToSupabase,
+ fetchAndStoreYouTubeStats,
+ pushStatsToSanity,
+} from "@/lib/youtube-stats";
+
+export async function POST(request: NextRequest) {
+ // Authenticate via CRON_SECRET
+ const authHeader = request.headers.get("authorization");
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
+ console.error("[YOUTUBE] Unauthorized request: invalid authorization header");
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const action = searchParams.get("action");
+
+ try {
+ console.log(`[YOUTUBE] POST handler started (action=${action ?? "all"})`);
+
+ const results: Record = {};
+
+ // Phase 1: Discover — sync Sanity videos to Supabase registry
+ if (!action || action === "discover") {
+ console.log("[YOUTUBE] Running discover phase...");
+ results.discover = await syncSanityVideosToSupabase();
+ }
+
+ // Phase 2: Fetch — poll YouTube API and store stats in Supabase
+ if (!action || action === "fetch") {
+ console.log("[YOUTUBE] Running fetch phase...");
+ results.fetch = await fetchAndStoreYouTubeStats();
+ }
+
+ // Phase 3: Sync — push updated stats from Supabase to Sanity
+ if (!action || action === "sync") {
+ console.log("[YOUTUBE] Running sync phase...");
+ results.sync = await pushStatsToSanity();
+ }
+
+ console.log("[YOUTUBE] Completed successfully", results);
+ return Response.json({ success: true, ...results });
+ } catch (error) {
+ console.error("[YOUTUBE] Unexpected error:", error);
+ return Response.json(
+ { success: false, error: String(error) },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
new file mode 100644
index 000000000..7f4d917ed
--- /dev/null
+++ b/apps/web/app/globals.css
@@ -0,0 +1,224 @@
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+@plugin "tailwindcss-animate";
+
+@custom-variant dark (&:is(.dark *));
+
+@layer base {
+ :root {
+ --radius: 0.65rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.606 0.25 292.717);
+ --primary-foreground: oklch(0.969 0.016 293.756);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.606 0.25 292.717);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.606 0.25 292.717);
+ --sidebar-primary-foreground: oklch(0.969 0.016 293.756);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.606 0.25 292.717);
+ }
+
+ .dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.541 0.281 293.009);
+ --primary-foreground: oklch(0.969 0.016 293.756);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.541 0.281 293.009);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.541 0.281 293.009);
+ --sidebar-primary-foreground: oklch(0.969 0.016 293.756);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.541 0.281 293.009);
+ }
+}
+
+@layer base {
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-inter), sans-serif;
+ }
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-family: var(--font-nunito), sans-serif;
+ }
+}
+
+.ais-SearchBox-input[type="search"]::-webkit-search-cancel-button {
+ display: none;
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
+
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
+
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+@keyframes float {
+ 0% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-20px);
+ }
+ 100% {
+ transform: translateY(0px);
+ }
+}
+
+@keyframes paw-one {
+ 0% {
+ transform: translate(0, 0);
+ }
+ 20% {
+ transform: translate(-40px, 40px) rotate(-15deg);
+ }
+ 40% {
+ transform: translate(-30px, 30px) rotate(-5deg);
+ }
+ 60% {
+ transform: translate(-40px, 40px) rotate(-15deg);
+ }
+ 80% {
+ transform: translate(-30px, 30px) rotate(-5deg);
+ }
+ 100% {
+ transform: translate(0, 0);
+ }
+}
+
+@keyframes paw-two {
+ 0% {
+ transform: translate(0, 0);
+ }
+ 20% {
+ transform: translate(40px, -40px) rotate(15deg);
+ }
+ 40% {
+ transform: translate(30px, -30px) rotate(5deg);
+ }
+ 60% {
+ transform: translate(40px, -40px) rotate(15deg);
+ }
+ 80% {
+ transform: translate(30px, -30px) rotate(5deg);
+ }
+ 100% {
+ transform: translate(0, 0);
+ }
+}
+
+.animate-paw-one {
+ animation: paw-one 8s ease-in-out infinite;
+}
+
+.animate-paw-two {
+ animation: paw-two 8s ease-in-out infinite 2s;
+}
diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts
new file mode 100644
index 000000000..bc40e78df
--- /dev/null
+++ b/apps/web/app/robots.ts
@@ -0,0 +1,17 @@
+import type { MetadataRoute } from "next";
+
+import { SITE_URL } from "@/lib/site";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [
+ {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/api/", "/dashboard/"],
+ },
+ ],
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ };
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
new file mode 100644
index 000000000..0236f3026
--- /dev/null
+++ b/apps/web/app/sitemap.ts
@@ -0,0 +1,46 @@
+import type { MetadataRoute } from "next";
+import { sitemapQuery } from "@/sanity/lib/queries";
+import {
+ sanityFetchMetadata,
+ getDynamicFetchOptions,
+} from "@/sanity/lib/live";
+import type { SitemapQueryResult } from "@/sanity/types";
+import { ContentType } from "@/lib/types";
+import { SITE_URL } from "@/lib/site";
+
+export default async function sitemap(): Promise {
+ const { perspective } = await getDynamicFetchOptions();
+ const content = (
+ await sanityFetchMetadata({
+ query: sitemapQuery,
+ perspective,
+ })
+ ).data as SitemapQueryResult;
+
+ const now = new Date();
+ const sitemap: MetadataRoute.Sitemap = [
+ {
+ url: `${SITE_URL}`,
+ lastModified: now,
+ changeFrequency: "monthly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/search`,
+ lastModified: now,
+ changeFrequency: "daily",
+ priority: 0.1,
+ },
+ ];
+
+ for (const c of content) {
+ sitemap.push({
+ url: `${SITE_URL}${c._type === ContentType.page ? `/${c.slug}` : `/${c._type}/${c.slug}`}`,
+ lastModified: c._updatedAt ? new Date(c._updatedAt) : now,
+ changeFrequency: "monthly",
+ priority: 0.5,
+ });
+ }
+
+ return sitemap;
+}
diff --git a/apps/web/astro.config.mjs b/apps/web/astro.config.mjs
deleted file mode 100644
index 31d8acd45..000000000
--- a/apps/web/astro.config.mjs
+++ /dev/null
@@ -1,113 +0,0 @@
-import { defineConfig } from "astro/config";
-import cloudflare from "@astrojs/cloudflare";
-import react from "@astrojs/react";
-import sanity from "@sanity/astro";
-import tailwindcss from "@tailwindcss/vite";
-import fs from "node:fs";
-import path from "node:path";
-
-// Load .env and .env.local into process.env before config runs.
-// (Using a function config with loadEnv broke virtual module resolution.)
-function loadEnvIntoProcess(dir) {
- for (const name of [".env", ".env.local"]) {
- const file = path.join(dir, name);
- try {
- const raw = fs.readFileSync(file, "utf8");
- for (const line of raw.split("\n")) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("#")) continue;
- const eq = trimmed.indexOf("=");
- if (eq === -1) continue;
- const key = trimmed.slice(0, eq).trim();
- let value = trimmed.slice(eq + 1).trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
- value = value.slice(1, -1).replace(/\\(.)/g, "$1");
- if (!Object.prototype.hasOwnProperty.call(process.env, key)) process.env[key] = value;
- }
- } catch {
- // ignore missing file
- }
- }
-}
-loadEnvIntoProcess(process.cwd());
-
-const sanityProjectId = process.env.SANITY_PROJECT_ID || "hfh83o0w";
-const sanityDataset = process.env.SANITY_DATASET || "production";
-
-/**
- * Vite plugin to inline font files as Uint8Array at build time.
- * Required for OG image generation on CF Workers (no filesystem access).
- */
-function rawFonts(extensions) {
- return {
- name: "vite-plugin-raw-fonts",
- enforce: "pre",
- resolveId(id, importer) {
- if (extensions.some((ext) => id.includes(ext))) {
- if (id.startsWith(".")) {
- return path.resolve(path.dirname(importer), id);
- }
- return id;
- }
- },
- load(id) {
- if (extensions.some((ext) => id.includes(ext))) {
- const buffer = fs.readFileSync(id);
- return `export default new Uint8Array([${Array.from(buffer).join(",")}]);`;
- }
- },
- };
-}
-
-export default defineConfig({
- output: "server",
- adapter: cloudflare({
- platformProxy: {
- enabled: true,
- },
- }),
- integrations: [
- sanity({
- projectId: sanityProjectId,
- dataset: sanityDataset,
- useCdn: false,
- apiVersion: "2026-03-17",
- // Visual Editing: stega encodes edit markers in strings
- // Studio is standalone (apps/sanity), not embedded — no studioBasePath
- stega: {
- studioUrl: "https://codingcat.dev.sanity.studio",
- },
- }),
- react(),
- ],
- vite: {
- plugins: [tailwindcss(), rawFonts([".ttf", ".otf"])],
- assetsInclude: ["**/*.wasm"],
- assetsExclude: ["**/*.ttf", "**/*.otf"],
- resolve: {
- alias: {
- // Sanity/visual-editing deps pull Node built-ins into client bundle; polyfill for browser
- stream: "stream-browserify",
- timers: "timers-browserify",
- },
- dedupe: ["react", "react-dom", "react-is", "react-compiler-runtime"],
- },
- optimizeDeps: {
- // Force pre-bundle CJS deps so ESM default/named exports work in client (Sanity visual-editing chain)
- include: [
- "react-is",
- "react-compiler-runtime",
- "lodash",
- "lodash/isObject",
- ],
- // Pre-bundling breaks under Cloudflare Workers SSR (missing deps_ssr chunk); load package as-is
- exclude: ["@sanity/preview-url-secret"],
- },
- ssr: {
- optimizeDeps: {
- exclude: ["@sanity/preview-url-secret"],
- },
- external: ["buffer", "path", "fs"].map((i) => `node:${i}`),
- },
- },
-});
diff --git a/apps/web/biome.json b/apps/web/biome.json
new file mode 100644
index 000000000..3e1a94011
--- /dev/null
+++ b/apps/web/biome.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+ "formatter": {
+ "enabled": true,
+ "useEditorconfig": true
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "suspicious": {
+ "noExplicitAny": "off",
+ "noArrayIndexKey": "off"
+ },
+ "complexity": {
+ "noForEach": "off"
+ },
+ "correctness": {
+ "useExhaustiveDependencies": "off",
+ "noUnusedFunctionParameters": "warn"
+ },
+ "style": {
+ "noUnusedTemplateLiteral": {
+ "level": "error",
+ "fix": "safe"
+ },
+ "noNonNullAssertion": "warn",
+ "useBlockStatements": "error"
+ },
+ "a11y": {
+ "noSvgWithoutTitle": "off"
+ }
+ }
+ },
+ "organizeImports": {
+ "enabled": true
+ },
+ "files": {
+ "ignore": ["node_modules/**", "sanity/types.ts", "supabase/**", "playwright-report/**", "test-results/**"]
+ },
+ "javascript": {
+ "jsxRuntime": "automatic",
+ "formatter": {
+ "trailingCommas": "all",
+ "semicolons": "always"
+ }
+ },
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "defaultBranch": "main",
+ "useIgnoreFile": true
+ }
+}
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 000000000..1e8a89d79
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/apps/web/components/algolia-dialog.tsx b/apps/web/components/algolia-dialog.tsx
new file mode 100644
index 000000000..41ac763bc
--- /dev/null
+++ b/apps/web/components/algolia-dialog.tsx
@@ -0,0 +1,44 @@
+"use client";
+import {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import AlgoliaSearch from "@/components/algolia-search";
+import { FaMagnifyingGlass } from "react-icons/fa6";
+import { useState } from "react";
+import { useKeyPress } from "@/lib/hooks";
+
+export default function AlgoliaDialog() {
+ const [open, setOpen] = useState(false);
+ useKeyPress(() => setOpen && setOpen(true), "KeyK");
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/algolia-search.tsx b/apps/web/components/algolia-search.tsx
new file mode 100644
index 000000000..6abcdff4b
--- /dev/null
+++ b/apps/web/components/algolia-search.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { liteClient as algoliasearch } from "algoliasearch/lite";
+import type { Hit as AlgoliaHit, SearchClient } from "instantsearch.js";
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { FaX } from "react-icons/fa6";
+
+import { FaPodcast } from "react-icons/fa"; // Podcast
+import { HiOutlinePencilAlt } from "react-icons/hi"; //Post
+import { FaCat } from "react-icons/fa"; // Author
+import { FaRegUser } from "react-icons/fa"; //Guest
+
+import {
+ Hits,
+ Highlight,
+ SearchBox,
+ RefinementList,
+ type UseDynamicWidgetsProps,
+ PoweredBy,
+} from "react-instantsearch";
+import { InstantSearchNext } from "react-instantsearch-nextjs";
+import { useDynamicWidgets } from "react-instantsearch";
+
+import type {
+ AuthorQueryResult,
+ GuestQueryResult,
+ PodcastQueryResult,
+ PostQueryResult,
+} from "@/sanity/types";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { ContentType } from "@/lib/types";
+
+const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
+const searchApiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY;
+const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX;
+
+type HitProps = {
+ hit: AlgoliaHit<
+ NonNullable<
+ | PodcastQueryResult
+ | PostQueryResult
+ | AuthorQueryResult
+ | GuestQueryResult
+ >
+ >;
+};
+
+export default function AlgoliaSearch({
+ showFacets = true,
+ setOpen,
+}: {
+ showFacets?: boolean;
+ setOpen?: (open: boolean) => void;
+}) {
+ const [client, setClient] = useState(null);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ if (appId && searchApiKey && indexName) {
+ setClient(algoliasearch(appId, searchApiKey));
+ }
+ }, []);
+
+ const openInSearch = () => {
+ const search = Array.from(searchParams.entries());
+ router.push(`/search?${search?.at(0)?.join("=")}`);
+ setOpen?.(false);
+ };
+
+ const iconPicker = (type: string) => {
+ switch (type) {
+ case ContentType.author:
+ return ;
+ case ContentType.guest:
+ return ;
+ case ContentType.podcast:
+ return ;
+ case ContentType.post:
+ return ;
+ }
+ };
+
+ const hitComponent = ({ hit }: HitProps) => {
+ return (
+ <>
+
+ {iconPicker(hit._type)}
+ setOpen?.(false)}
+ >
+
+
+
+
+
+ {!showFacets && (
+
+
+
+ )}
+
+ >
+ );
+ };
+
+ return (
+ <>
+ {client && indexName ? (
+
+
+
+ {showFacets && (
+
+
+
+ )}
+
+
+
+
null}
+ resetIconComponent={() => }
+ />
+
+
+
+ Results
+
+
+
+
+
+
+
+ ) : (
+
+ Missing Algolia Config
+
+ )}
+ >
+ );
+}
+
+function CustomDynamicWidgets(props: UseDynamicWidgetsProps | undefined) {
+ const { attributesToRender } = useDynamicWidgets(props);
+ return attributesToRender.map((attribute) => (
+
+
+ {attribute === "_type" ? "Type" : attribute}
+
+
+
+ ));
+}
diff --git a/apps/web/components/analytics.tsx b/apps/web/components/analytics.tsx
new file mode 100644
index 000000000..55759610a
--- /dev/null
+++ b/apps/web/components/analytics.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { Analytics } from "@vercel/analytics/next";
+import { SpeedInsights } from "@vercel/speed-insights/next";
+import Script from "next/script";
+
+export function SiteAnalytics() {
+ return (
+ <>
+
+
+ {process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/animated-hero.tsx b/apps/web/components/animated-hero.tsx
new file mode 100644
index 000000000..2b25ce906
--- /dev/null
+++ b/apps/web/components/animated-hero.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useState } from "react";
+import {
+ FaReact,
+ FaVuejs,
+ FaAngular,
+ FaNodeJs,
+ FaPython,
+} from "react-icons/fa";
+import { SiSvelte } from "react-icons/si";
+import {
+ SiNextdotjs,
+ SiTypescript,
+ SiJavascript,
+ SiOpenai,
+ SiTensorflow,
+} from "react-icons/si";
+import { RiGeminiFill } from "react-icons/ri";
+import AJHeadphones from "@/components/icons/aj-headphones";
+import AJPrimary from "@/components/icons/aj-primary-alt";
+
+const icons = [
+ { icon: , name: "React" },
+ { icon: , name: "Vue.js" },
+ { icon: , name: "Angular" },
+ { icon: , name: "Svelte" },
+ { icon: , name: "Node.js" },
+ { icon: , name: "Next.js" },
+ { icon: , name: "TypeScript" },
+ { icon: , name: "JavaScript" },
+ { icon: , name: "Python" },
+ { icon: , name: "OpenAI" },
+ { icon: , name: "TensorFlow" },
+ { icon: , name: "Gemini" },
+];
+
+export default function AnimatedHero() {
+ const [isHovered, setIsHovered] = useState(false);
+
+ return (
+
+
+ {icons.map((item, index) => (
+
+ {item.icon}
+
+ ))}
+
+
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+ CodingCat.dev Podcast
+
+
+ Purrfect Podcast for Web Developers
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/app-sidebar.tsx b/apps/web/components/app-sidebar.tsx
new file mode 100644
index 000000000..d6424919a
--- /dev/null
+++ b/apps/web/components/app-sidebar.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+import {
+ LayoutDashboard,
+ Lightbulb,
+ FileVideo,
+ Handshake,
+ Settings,
+ ClipboardCheck,
+ Activity,
+ Cog,
+} from "lucide-react"
+
+import { NavMain } from "@/components/nav-main"
+import { NavUser } from "@/components/nav-user"
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+} from "@/components/ui/sidebar"
+import Link from "next/link"
+
+const navItems = [
+ {
+ title: "Dashboard",
+ url: "/dashboard",
+ icon: LayoutDashboard,
+ },
+ {
+ title: "Review Queue",
+ url: "/dashboard/review",
+ icon: ClipboardCheck,
+ },
+ {
+ title: "Pipeline",
+ url: "/dashboard/pipeline",
+ icon: Activity,
+ },
+ {
+ title: "Content",
+ url: "/dashboard/content",
+ icon: Lightbulb,
+ },
+ {
+ title: "Videos",
+ url: "/dashboard/videos",
+ icon: FileVideo,
+ },
+ {
+ title: "Sponsors",
+ url: "/dashboard/sponsors",
+ icon: Handshake,
+ },
+ {
+ title: "Config",
+ url: "/dashboard/config",
+ icon: Cog,
+ },
+ {
+ title: "Settings",
+ url: "/dashboard/settings",
+ icon: Settings,
+ },
+]
+
+const defaultUser = {
+ name: "CodingCat",
+ email: "admin@codingcat.dev",
+ avatar: "",
+}
+
+export function AppSidebar({
+ user,
+ ...props
+}: React.ComponentProps & {
+ user?: { email?: string; user_metadata?: Record }
+}) {
+ const userData = user
+ ? {
+ name:
+ user.user_metadata?.full_name ||
+ user.email?.split("@")[0] ||
+ "User",
+ email: user.email || "",
+ avatar: user.user_metadata?.avatar_url || "",
+ }
+ : defaultUser
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ CodingCat.dev
+ Content Ops
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/avatar.tsx b/apps/web/components/avatar.tsx
new file mode 100644
index 000000000..1a75ca684
--- /dev/null
+++ b/apps/web/components/avatar.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import Image from "next/image";
+import type { Author } from "@/sanity/types";
+import Link from "next/link";
+import { urlForImage } from "@/sanity/lib/image";
+
+interface Props {
+ name?: string;
+ href?: string;
+ coverImage: Exclude | undefined;
+ imgSize?: string;
+ width?: number;
+ height?: number;
+}
+
+export default function Avatar({
+ name,
+ href,
+ coverImage,
+ imgSize,
+ width,
+ height,
+}: Props) {
+ const imageUrl = coverImage?.asset?._ref
+ ? urlForImage(coverImage)?.width(width || 48).height(height || 48).url()
+ : null;
+
+ if (!imageUrl) return <>>;
+
+ const imageElement = (
+
+
+
+ );
+
+ if (!href) return imageElement;
+
+ return (
+
+ {imageElement}
+ {name && (
+
+ {name}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/badge-pro.tsx b/apps/web/components/badge-pro.tsx
new file mode 100644
index 000000000..269c1ec1c
--- /dev/null
+++ b/apps/web/components/badge-pro.tsx
@@ -0,0 +1,19 @@
+import { FaLock, FaUnlock } from "react-icons/fa";
+import { Badge } from "./ui/badge";
+
+interface BadgePro {
+ locked: boolean | null | undefined;
+ hideLabel?: boolean;
+}
+
+export default function BadgePro(props: BadgePro) {
+ const { locked, hideLabel = false } = props;
+ return (
+
+ {hideLabel ? null : (
+ {locked ? "Pro:" : "Public"}
+ )}
+ {locked ? : }
+
+ );
+}
diff --git a/apps/web/components/become-sponsor-popup.tsx b/apps/web/components/become-sponsor-popup.tsx
new file mode 100644
index 000000000..34d64861a
--- /dev/null
+++ b/apps/web/components/become-sponsor-popup.tsx
@@ -0,0 +1,48 @@
+
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import Link from 'next/link';
+
+export function BecomeSponsorPopup() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setIsOpen(true);
+ }, 5000); // 5 seconds
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+
+
+
+ Become a Sponsor!
+
+ Enjoying the content? Help us keep it going by becoming a sponsor.
+ You'll get your brand in front of a large audience of developers.
+
+
+
+ Maybe later
+
+ Learn More
+
+
+
+
+ );
+}
diff --git a/apps/web/components/block-code-btn.tsx b/apps/web/components/block-code-btn.tsx
new file mode 100644
index 000000000..6e926eb1d
--- /dev/null
+++ b/apps/web/components/block-code-btn.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { useState } from "react";
+import { LuClipboard, LuCheck } from "react-icons/lu";
+
+export default function BlockCodeButton({ code }: { code: string }) {
+ const startIcon = ;
+ const copiedIcon = ;
+ const [icon, setIcon] = useState(startIcon);
+ const copy = () => {
+ navigator.clipboard.writeText(code);
+ setIcon(copiedIcon);
+ setTimeout(() => {
+ setIcon(startIcon);
+ }, 1500);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/block-code.tsx b/apps/web/components/block-code.tsx
new file mode 100644
index 000000000..a297f5eb7
--- /dev/null
+++ b/apps/web/components/block-code.tsx
@@ -0,0 +1,36 @@
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
+
+import BlockCodeButton from "@/components/block-code-btn";
+
+import { prismLanguages } from "@/lib/prism";
+
+interface CodeProps {
+ code: string;
+ language?: string;
+}
+
+export default function BlockCode(props: CodeProps) {
+ const { code, language } = props;
+
+ // See https://raw.githubusercontent.com/react-syntax-highlighter/react-syntax-highlighter/master/AVAILABLE_LANGUAGES_PRISM.MD
+
+ let cleanLanguage = "typescript";
+ if (language && prismLanguages.includes(language)) {
+ cleanLanguage = language;
+ }
+
+ return (
+
+
+
+ {code}
+
+
+ );
+}
diff --git a/apps/web/components/block-image.tsx b/apps/web/components/block-image.tsx
new file mode 100644
index 000000000..b3dfd144d
--- /dev/null
+++ b/apps/web/components/block-image.tsx
@@ -0,0 +1,36 @@
+import Image from "next/image";
+import { urlForImage } from "@/sanity/lib/image";
+
+interface BlockImageProps {
+ image: any;
+}
+
+export default function BlockImage(props: BlockImageProps) {
+ const { image } = props;
+
+ const imageUrl = image?.asset?._ref
+ ? urlForImage(image)?.width(1920).height(1080).url()
+ : null;
+
+ if (!imageUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/block-table.tsx b/apps/web/components/block-table.tsx
new file mode 100644
index 000000000..1e0c799b9
--- /dev/null
+++ b/apps/web/components/block-table.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import ReactMarkdown from "react-markdown";
+
+export default function BlockTable({
+ value,
+}: {
+ value: { rows: { _key: string; cells: string[] }[] };
+}) {
+ const { rows } = value;
+ if (!rows) {
+ return null;
+ }
+ return (
+
+
+ {rows.map((row) => (
+
+ {row.cells.map((cell, cellIndex) => (
+ |
+ {cell}
+ |
+ ))}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/components/bookmark.tsx b/apps/web/components/bookmark.tsx
new file mode 100644
index 000000000..38cb1b62e
--- /dev/null
+++ b/apps/web/components/bookmark.tsx
@@ -0,0 +1,5 @@
+"use client";
+
+export default function Bookmark() {
+ return <>>;
+}
diff --git a/apps/web/components/breadrumb-links.tsx b/apps/web/components/breadrumb-links.tsx
new file mode 100644
index 000000000..552ee9b97
--- /dev/null
+++ b/apps/web/components/breadrumb-links.tsx
@@ -0,0 +1,43 @@
+import Link from "next/link";
+
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import React from "react";
+
+export function BreadcrumbLinks({
+ links,
+}: {
+ links: { title: string; href?: string }[];
+}) {
+ return (
+
+
+
+
+ Home
+
+
+ {links?.map((link, i) => (
+
+
+
+ {link?.href ? (
+
+ {link.title}
+
+ ) : (
+ {link.title}
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/components/cloudflare-turnstile.tsx b/apps/web/components/cloudflare-turnstile.tsx
new file mode 100644
index 000000000..311f2266c
--- /dev/null
+++ b/apps/web/components/cloudflare-turnstile.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Turnstile } from "@marsidev/react-turnstile";
+
+export function CloudflareTurnstileWidget({
+ value,
+ onChange,
+ ...props
+}: {
+ value?: string;
+ onChange?: (token: string) => void;
+ [key: string]: any;
+}) {
+ return (
+ {
+ if (onChange) {
+ onChange(token);
+ }
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/apps/web/components/codepen-embed.tsx b/apps/web/components/codepen-embed.tsx
new file mode 100644
index 000000000..d4d4db20b
--- /dev/null
+++ b/apps/web/components/codepen-embed.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+export default function CodePenEmbed(props: any) {
+ const { url } = props;
+ if (!url) {
+ return Add a CodePen URL
;
+ }
+ const splitURL = url.split("/");
+ const [, , , user, , hash] = splitURL;
+ const embedUrl = `https://codepen.io/${user}/embed/${hash}?height=370&theme-id=dark&default-tab=result`;
+ return (
+
+ );
+}
diff --git a/apps/web/components/codesandbox-embed.tsx b/apps/web/components/codesandbox-embed.tsx
new file mode 100644
index 000000000..6895dc7df
--- /dev/null
+++ b/apps/web/components/codesandbox-embed.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+export default function CodeSandboxEmbed(props: any) {
+ const { url } = props;
+ if (!url) {
+ return Add a CodePen URL
;
+ }
+ const splitURL = url.split("/").at(-1).split("-").at(-1);
+ const embedUrl = `https://codesandbox.io/embed/${splitURL}`;
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/cookies-provider-client.tsx b/apps/web/components/cookies-provider-client.tsx
new file mode 100644
index 000000000..92fcc0632
--- /dev/null
+++ b/apps/web/components/cookies-provider-client.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { CookiesProvider } from "react-cookie";
+
+export default function CookiesProviderClient({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children};
+}
diff --git a/apps/web/components/cookies-provider.tsx b/apps/web/components/cookies-provider.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/web/components/cover-image.tsx b/apps/web/components/cover-image.tsx
new file mode 100644
index 000000000..a5c79b5c8
--- /dev/null
+++ b/apps/web/components/cover-image.tsx
@@ -0,0 +1,47 @@
+import Image from "next/image";
+import { urlForImage } from "@/sanity/lib/image";
+
+interface CoverImageProps {
+ image: any;
+ priority?: boolean;
+ className?: string;
+ width?: number;
+ height?: number;
+ quality?: number;
+}
+
+export default function CoverImage(props: CoverImageProps) {
+ const { image, priority, className, width, height, quality } = props;
+
+ const imageUrl = image?.asset?._ref
+ ? urlForImage(image)?.width(width || 1920).height(height || 1080).quality(quality || 80).url()
+ : null;
+
+ // TODO: Add LQIP blur placeholder for progressive loading. Options:
+ // 1. Query Sanity for lqip metadata: image.asset->metadata.lqip
+ // 2. Use a tiny base64 placeholder generated at build time
+
+ if (!imageUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/cover-media.tsx b/apps/web/components/cover-media.tsx
new file mode 100644
index 000000000..e9ad9d910
--- /dev/null
+++ b/apps/web/components/cover-media.tsx
@@ -0,0 +1,36 @@
+import dynamic from "next/dynamic";
+
+const YouTube = dynamic(() =>
+ import("@/components/youtube").then((mod) => mod.YouTube),
+);
+const CoverImage = dynamic(() => import("@/components/cover-image"));
+const CoverVideo = dynamic(() => import("@/components/cover-video"));
+
+export interface CoverMediaProps {
+ cloudinaryImage: any;
+ cloudinaryVideo: any;
+ youtube: string | null | undefined;
+ className?: string;
+}
+
+export default function CoverMedia(props: CoverMediaProps) {
+ const { cloudinaryImage, cloudinaryVideo, youtube, className } = props;
+
+ if (cloudinaryVideo?.asset?._ref) {
+ return (
+
+ );
+ }
+ if (youtube) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/apps/web/components/cover-video.tsx b/apps/web/components/cover-video.tsx
new file mode 100644
index 000000000..b294fa50a
--- /dev/null
+++ b/apps/web/components/cover-video.tsx
@@ -0,0 +1,43 @@
+interface CoverVideoProps {
+ cloudinaryVideo: any;
+ className?: string;
+}
+
+export default function CoverVideo(props: CoverVideoProps) {
+ const { cloudinaryVideo, className } = props;
+
+ // After migration, cloudinaryVideo is { _type: "file", asset: { _ref: "file-xxx-ext" } }
+ // We need to construct the URL from the asset ref
+ const assetRef = cloudinaryVideo?.asset?._ref;
+
+ if (!assetRef) {
+ return (
+
+ );
+ }
+
+ // Sanity file asset references follow the format: file-{id}-{extension}
+ // e.g., "file-abc123def456-mp4" → https://cdn.sanity.io/files/{projectId}/{dataset}/abc123def456.mp4
+ const parts = assetRef.split('-');
+ const ext = parts.pop();
+ const hash = parts.slice(1).join('-');
+ const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
+ const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET;
+ const videoUrl = `https://cdn.sanity.io/files/${projectId}/${dataset}/${hash}.${ext}`;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/date.tsx b/apps/web/components/date.tsx
new file mode 100644
index 000000000..d4878f7c2
--- /dev/null
+++ b/apps/web/components/date.tsx
@@ -0,0 +1,9 @@
+import { format } from "date-fns";
+
+export default function DateComponent({ dateString }: { dateString: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/disable-draft-mode.tsx b/apps/web/components/disable-draft-mode.tsx
new file mode 100644
index 000000000..243133172
--- /dev/null
+++ b/apps/web/components/disable-draft-mode.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useVisualEditingEnvironment } from "next-sanity/hooks";
+import Link from "next/link";
+
+export function DisableDraftMode() {
+ const environment = useVisualEditingEnvironment();
+
+ // Only show the disable draft mode button when outside of Presentation Tool
+ if (
+ environment === "presentation-iframe" ||
+ environment === "presentation-window"
+ ) {
+ return null;
+ }
+
+ return (
+
+ Disable Draft Mode
+
+ );
+}
diff --git a/apps/web/components/footer.tsx b/apps/web/components/footer.tsx
new file mode 100644
index 000000000..7c7570d76
--- /dev/null
+++ b/apps/web/components/footer.tsx
@@ -0,0 +1,124 @@
+import {
+ FaGithub,
+ FaLinkedin,
+ FaSquareXTwitter,
+ FaYoutube,
+} from "react-icons/fa6";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import NavLink from "@/components/nav-link";
+
+// Evaluated once at module load (server start), not during render, so it
+// doesn't trip the Cache Components prerender guard against `new Date()`.
+const CURRENT_YEAR = new Date().getFullYear();
+
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/apps/web/components/google-ad-banner.tsx b/apps/web/components/google-ad-banner.tsx
new file mode 100644
index 000000000..f92a8370b
--- /dev/null
+++ b/apps/web/components/google-ad-banner.tsx
@@ -0,0 +1,64 @@
+"use client";
+import Router from "next/router";
+import { useEffect } from "react";
+declare global {
+ interface Window {
+ adsbygoogle: unknown[];
+ }
+}
+
+interface AdsBannerProps {
+ "data-ad-slot": string;
+ "data-ad-format": string;
+ "data-full-width-responsive": string;
+ "data-ad-layout"?: string;
+}
+
+const GoogleAdBanner = (props: AdsBannerProps) => {
+ useEffect(() => {
+ const handleRouteChange = () => {
+ const intervalId = setInterval(() => {
+ try {
+ // Check if the 'ins' element already has an ad in it
+ if (window.adsbygoogle) {
+ window.adsbygoogle.push({});
+ clearInterval(intervalId);
+ }
+ } catch (err) {
+ console.error("Error pushing ads: ", err);
+ clearInterval(intervalId); // Ensure we clear interval on errors too
+ }
+ }, 100);
+ return () => clearInterval(intervalId); // Clear interval on component unmount
+ };
+
+ // Run the function when the component mounts
+ handleRouteChange();
+
+ // Subscribe to route changes
+ if (typeof window !== "undefined") {
+ Router.events.on("routeChangeComplete", handleRouteChange);
+
+ // Unsubscribe from route changes when the component unmounts
+ return () => {
+ Router.events.off("routeChangeComplete", handleRouteChange);
+ };
+ }
+ }, []);
+
+ return (
+
+ );
+};
+export default GoogleAdBanner;
diff --git a/apps/web/components/html-embed.tsx b/apps/web/components/html-embed.tsx
new file mode 100644
index 000000000..cc7d1ca3c
--- /dev/null
+++ b/apps/web/components/html-embed.tsx
@@ -0,0 +1,7 @@
+export default function HTMLEmbed(props: any) {
+ const { html } = props;
+ if (!html) {
+ return Hello World!
;
+ }
+ return ;
+}
diff --git a/apps/web/components/icons.tsx b/apps/web/components/icons.tsx
new file mode 100644
index 000000000..b81207b3d
--- /dev/null
+++ b/apps/web/components/icons.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { Lightbulb, ExternalLink } from "lucide-react";
diff --git a/apps/web/components/icons/aj-alt.tsx b/apps/web/components/icons/aj-alt.tsx
new file mode 100644
index 000000000..0ddd0e224
--- /dev/null
+++ b/apps/web/components/icons/aj-alt.tsx
@@ -0,0 +1,118 @@
+export default function AjAlt({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/aj-dark.tsx b/apps/web/components/icons/aj-dark.tsx
new file mode 100644
index 000000000..05a5928be
--- /dev/null
+++ b/apps/web/components/icons/aj-dark.tsx
@@ -0,0 +1,89 @@
+export default function AJDark({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/aj-headphones-alt.tsx b/apps/web/components/icons/aj-headphones-alt.tsx
new file mode 100644
index 000000000..5bb65c421
--- /dev/null
+++ b/apps/web/components/icons/aj-headphones-alt.tsx
@@ -0,0 +1,258 @@
+export default function AJHeadphonesAlt({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/aj-headphones.tsx b/apps/web/components/icons/aj-headphones.tsx
new file mode 100644
index 000000000..745d36450
--- /dev/null
+++ b/apps/web/components/icons/aj-headphones.tsx
@@ -0,0 +1,253 @@
+export default function AJHeadphones({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/aj-light.tsx b/apps/web/components/icons/aj-light.tsx
new file mode 100644
index 000000000..00359d6b6
--- /dev/null
+++ b/apps/web/components/icons/aj-light.tsx
@@ -0,0 +1,89 @@
+export default function AJLight({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/aj-primary-alt.tsx b/apps/web/components/icons/aj-primary-alt.tsx
new file mode 100644
index 000000000..0a3a54434
--- /dev/null
+++ b/apps/web/components/icons/aj-primary-alt.tsx
@@ -0,0 +1,154 @@
+export default function AJHeadphonesAlt({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/aj-primary.tsx b/apps/web/components/icons/aj-primary.tsx
new file mode 100644
index 000000000..e0bea73c8
--- /dev/null
+++ b/apps/web/components/icons/aj-primary.tsx
@@ -0,0 +1,152 @@
+export default function AJPrimary({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/checkout-arrow.tsx b/apps/web/components/icons/checkout-arrow.tsx
new file mode 100644
index 000000000..ffd6c0ac7
--- /dev/null
+++ b/apps/web/components/icons/checkout-arrow.tsx
@@ -0,0 +1,18 @@
+export default function CheckoutArrow({ cls = "block w-12 h-12" }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/icons/dribble.svg b/apps/web/components/icons/dribble.svg
new file mode 100644
index 000000000..877151068
--- /dev/null
+++ b/apps/web/components/icons/dribble.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/apps/web/components/json-ld.tsx b/apps/web/components/json-ld.tsx
new file mode 100644
index 000000000..4c012d7c8
--- /dev/null
+++ b/apps/web/components/json-ld.tsx
@@ -0,0 +1,15 @@
+import type { Thing, WithContext } from "schema-dts";
+
+/**
+ * Renders JSON-LD structured data. Data must come from a trusted source (the
+ * CMS); we do not render user-generated HTML here.
+ */
+export function JsonLd({ data }: { data: WithContext }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/mode-toggle.tsx b/apps/web/components/mode-toggle.tsx
new file mode 100644
index 000000000..ecb023317
--- /dev/null
+++ b/apps/web/components/mode-toggle.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import * as React from "react";
+import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
+import { useTheme } from "next-themes";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function ModeToggle() {
+ const { setTheme } = useTheme();
+
+ return (
+
+
+
+
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ );
+}
diff --git a/apps/web/components/more-content.tsx b/apps/web/components/more-content.tsx
new file mode 100644
index 000000000..805d053b7
--- /dev/null
+++ b/apps/web/components/more-content.tsx
@@ -0,0 +1,141 @@
+import Link from "next/link";
+
+import Avatar from "@/components/avatar";
+import CoverImage from "@/components/cover-image";
+import DateComponent from "@/components/date";
+import { Button } from "@/components/ui/button";
+
+import type {
+ MorePodcastQueryResult,
+ MorePostQueryResult,
+} from "@/sanity/types";
+import { sanityFetch, type DynamicFetchOptions } from "@/sanity/lib/live";
+import {
+ morePodcastQuery,
+ morePostQuery,
+ moreAuthorQuery,
+ moreGuestQuery,
+ moreSponsorQuery,
+} from "@/sanity/lib/queries";
+import { ContentType } from "@/lib/types";
+import { pluralize } from "@/lib/utils";
+
+export default async function MoreContent(
+ params: {
+ type: string;
+ skip?: string;
+ limit?: number;
+ offset?: number;
+ showHeader?: boolean;
+ } & DynamicFetchOptions,
+) {
+ "use cache";
+ const { perspective, stega } = params;
+ const whichQuery = () => {
+ switch (params.type) {
+ case ContentType.author:
+ return moreAuthorQuery;
+ case ContentType.guest:
+ return moreGuestQuery;
+ case ContentType.podcast:
+ return morePodcastQuery;
+ case ContentType.sponsor:
+ return moreSponsorQuery;
+ default:
+ return morePostQuery;
+ }
+ };
+
+ const data = (
+ await sanityFetch({
+ query: whichQuery(),
+ params: {
+ type: params.type,
+ skip: params.skip || "none",
+ limit: params.limit || 4,
+ offset: params.offset || 0,
+ },
+ perspective,
+ stega,
+ })
+ ).data as MorePodcastQueryResult;
+
+ return (
+
+ {params?.showHeader && (
+ <>
+
+ {pluralize(params.type)}
+
+
+ >
+ )}
+
+ {data?.map((post) => {
+ const {
+ _id,
+ _type,
+ title,
+ slug,
+ coverImage,
+ excerpt,
+ author,
+ guest,
+ } = post;
+ return (
+
+
+ {["author", "guest"].includes(_type) && coverImage ? (
+
+ ) : (
+
+ )}
+
+
+
+ {title}
+
+
+ {!["author", "guest"].includes(_type) && (
+
+
+
+ )}
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+ {(author || guest) && (
+
+ {author?.map((a) => (
+
+ ))}
+ {guest?.map((a) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/components/more-header.tsx b/apps/web/components/more-header.tsx
new file mode 100644
index 000000000..064bec31c
--- /dev/null
+++ b/apps/web/components/more-header.tsx
@@ -0,0 +1,32 @@
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+
+export default function MoreHeader({
+ title,
+ href,
+ text = "View More",
+ showHr = true,
+}: {
+ title: string;
+ href: string;
+ text?: string;
+ showHr?: boolean;
+}) {
+ return (
+ <>
+ {showHr &&
}
+
+
+
+ {title}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/components/nav-header.tsx b/apps/web/components/nav-header.tsx
new file mode 100644
index 000000000..584cee136
--- /dev/null
+++ b/apps/web/components/nav-header.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import type { Settings } from "@/sanity/types";
+
+import { useActivePath } from "@/lib/hooks";
+import NavLink from "@/components/nav-link";
+import { SheetClose } from "./ui/sheet";
+
+interface Props {
+ navLinks: Exclude | undefined;
+ className?: string;
+ sideOnly?: boolean;
+}
+
+export default function NavHeader({
+ navLinks,
+ className,
+ sideOnly,
+ ...restProps
+}: Props) {
+ const checkActivePath = useActivePath();
+ return (
+ <>
+ {navLinks
+ ?.filter((l) => (sideOnly ? true : l?.sideOnly !== true))
+ ?.map((l) =>
+ sideOnly ? (
+
+
+ {l.title}
+
+
+ ) : (
+
+ {l.title}
+
+ ),
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/nav-link.tsx b/apps/web/components/nav-link.tsx
new file mode 100644
index 000000000..03fea23c6
--- /dev/null
+++ b/apps/web/components/nav-link.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import Link, { type LinkProps } from "next/link";
+
+import { useActivePath } from "@/lib/hooks";
+
+interface Props extends LinkProps {
+ className?: string;
+ children: React.ReactNode;
+ target?: string;
+ rel?: string;
+}
+
+export default function NavLink(props: Props) {
+ const { href, className, children } = props;
+ const checkActivePath = useActivePath();
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/components/nav-main.tsx b/apps/web/components/nav-main.tsx
new file mode 100644
index 000000000..1e24327dd
--- /dev/null
+++ b/apps/web/components/nav-main.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import { type LucideIcon } from "lucide-react"
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar"
+
+export function NavMain({
+ items,
+}: {
+ items: {
+ title: string
+ url: string
+ icon?: LucideIcon
+ isActive?: boolean
+ }[]
+}) {
+ const pathname = usePathname()
+
+ return (
+
+ Content Ops
+
+
+ {items.map((item) => (
+
+
+
+ {item.icon && }
+ {item.title}
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/components/nav-user.tsx b/apps/web/components/nav-user.tsx
new file mode 100644
index 000000000..0fc43f05f
--- /dev/null
+++ b/apps/web/components/nav-user.tsx
@@ -0,0 +1,113 @@
+"use client"
+
+import {
+ BadgeCheck,
+ ChevronsUpDown,
+ LogOut,
+} from "lucide-react"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@/components/ui/avatar"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar"
+import { createClient } from "@/lib/supabase/client"
+import { useRouter } from "next/navigation"
+
+export function NavUser({
+ user,
+}: {
+ user: {
+ name: string
+ email: string
+ avatar: string
+ }
+}) {
+ const { isMobile } = useSidebar()
+ const router = useRouter()
+
+ const handleSignOut = async () => {
+ try {
+ const supabase = createClient()
+ await supabase.auth.signOut()
+ router.push("/dashboard/login")
+ } catch {
+ router.push("/dashboard/login")
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {user.name.slice(0, 2).toUpperCase()}
+
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+
+
+
+
+
+ {user.name.slice(0, 2).toUpperCase()}
+
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+
+
+ Account
+
+
+
+
+
+ Sign out
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding.tsx b/apps/web/components/onboarding.tsx
new file mode 100644
index 000000000..6e5755663
--- /dev/null
+++ b/apps/web/components/onboarding.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+/**
+ * This file is used for onboarding when you don't have any posts yet and are using the template for the first time.
+ * Once you have content, and know where to go to access the Sanity Studio and create content, you can delete this file.
+ */
+
+import Link from "next/link";
+import { useSyncExternalStore } from "react";
+
+const emptySubscribe = () => () => {};
+
+export default function Onboarding() {
+ const target = useSyncExternalStore(
+ emptySubscribe,
+ () => (window.top === window ? undefined : "_blank"),
+ () => "_blank",
+ );
+
+ return (
+
+
+
+
No posts
+
+ Get started by creating a new post.
+
+
+
+
+
+
+ Create Post
+
+
+
+ );
+}
diff --git a/apps/web/components/page-refresh-button.tsx b/apps/web/components/page-refresh-button.tsx
new file mode 100644
index 000000000..445e74cb0
--- /dev/null
+++ b/apps/web/components/page-refresh-button.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { RefreshCw } from "lucide-react";
+
+export function PageRefreshButton() {
+ const router = useRouter();
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleRefresh = () => {
+ setIsRefreshing(true);
+ router.refresh();
+ setTimeout(() => setIsRefreshing(false), 1000);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/paginate-list.tsx b/apps/web/components/paginate-list.tsx
new file mode 100644
index 000000000..6215c356d
--- /dev/null
+++ b/apps/web/components/paginate-list.tsx
@@ -0,0 +1,75 @@
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+
+export default async function PaginateList({
+ base,
+ num,
+ limit,
+ count,
+}: {
+ base: string;
+ num: number;
+ limit: number;
+ count: number;
+}) {
+ const pageNumber = Number(num);
+ const offset = (pageNumber - 1) * limit;
+ const offsetLimit = offset + limit;
+ const total = Math.ceil((count || 1) / limit);
+
+ return (
+
+
+
+ {pageNumber > 1 && (
+ <>
+
+
+
+
+ 1
+
+ >
+ )}
+ {pageNumber > 2 && (
+
+
+
+ )}
+
+
+ {pageNumber}
+
+
+ {pageNumber < total - 1 && (
+
+
+
+ )}
+ {pageNumber !== total && (
+
+
+ {total}
+
+
+ )}
+ {pageNumber < total && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/components/pipeline-status.tsx b/apps/web/components/pipeline-status.tsx
new file mode 100644
index 000000000..fa20588bc
--- /dev/null
+++ b/apps/web/components/pipeline-status.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Loader2 } from "lucide-react";
+import { POLL_INTERVAL_MS } from "@/lib/types/dashboard";
+
+interface PipelineData {
+ draft: number;
+ scriptReady: number;
+ audioGen: number;
+ rendering: number;
+ videoGen: number;
+ flagged: number;
+ uploading: number;
+ published: number;
+ total: number;
+}
+
+const STAGES: {
+ key: keyof Omit;
+ label: string;
+ color: string;
+ bg: string;
+ ring: string;
+}[] = [
+ { key: "draft", label: "Draft", color: "text-gray-700 dark:text-gray-300", bg: "bg-gray-200 dark:bg-gray-700", ring: "ring-gray-300 dark:ring-gray-600" },
+ { key: "scriptReady", label: "Script", color: "text-yellow-700 dark:text-yellow-300", bg: "bg-yellow-200 dark:bg-yellow-800", ring: "ring-yellow-300 dark:ring-yellow-600" },
+ { key: "audioGen", label: "Audio", color: "text-orange-700 dark:text-orange-300", bg: "bg-orange-200 dark:bg-orange-800", ring: "ring-orange-300 dark:ring-orange-600" },
+ { key: "rendering", label: "Render", color: "text-cyan-700 dark:text-cyan-300", bg: "bg-cyan-200 dark:bg-cyan-800", ring: "ring-cyan-300 dark:ring-cyan-600" },
+ { key: "videoGen", label: "Video", color: "text-blue-700 dark:text-blue-300", bg: "bg-blue-200 dark:bg-blue-800", ring: "ring-blue-300 dark:ring-blue-600" },
+ { key: "flagged", label: "Flagged", color: "text-red-700 dark:text-red-300", bg: "bg-red-200 dark:bg-red-800", ring: "ring-red-300 dark:ring-red-600" },
+ { key: "uploading", label: "Upload", color: "text-purple-700 dark:text-purple-300", bg: "bg-purple-200 dark:bg-purple-800", ring: "ring-purple-300 dark:ring-purple-600" },
+ { key: "published", label: "Published", color: "text-green-700 dark:text-green-300", bg: "bg-green-200 dark:bg-green-800", ring: "ring-green-300 dark:ring-green-600" },
+];
+
+export function PipelineStatus() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const abortRef = useRef(null);
+
+ const fetchPipeline = useCallback(async () => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ try {
+ const res = await fetch("/api/dashboard/pipeline", {
+ signal: controller.signal,
+ });
+ if (res.ok) {
+ setData(await res.json());
+ }
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") return;
+ // Silently fail — will retry on next poll
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchPipeline();
+ const interval = setInterval(fetchPipeline, POLL_INTERVAL_MS);
+ return () => {
+ clearInterval(interval);
+ abortRef.current?.abort();
+ };
+ }, [fetchPipeline]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ Unable to load pipeline data.
+
+ );
+ }
+
+ return (
+
+ {/* Stage indicators */}
+
+ {STAGES.map((stage) => {
+ const count = data[stage.key];
+ const maxCount = Math.max(...STAGES.map((s) => data[s.key]), 1);
+ const heightPct = Math.max((count / maxCount) * 100, 8);
+
+ return (
+
+ {/* Count */}
+
+ {count}
+
+ {/* Bar */}
+
+ {/* Label */}
+
+ {stage.label}
+
+
+ );
+ })}
+
+
+ {/* Connector arrows */}
+
+ {STAGES.map((stage, i) => (
+
+
+ {i < STAGES.length - 1 && (
+
\u2192
+ )}
+
+ ))}
+
+
+ {/* Total */}
+
+ Total in pipeline
+ {data.total}
+
+
+ );
+}
diff --git a/apps/web/components/player-context.tsx b/apps/web/components/player-context.tsx
new file mode 100644
index 000000000..022ee925d
--- /dev/null
+++ b/apps/web/components/player-context.tsx
@@ -0,0 +1,192 @@
+"use client";
+import type { PodcastQueryResult } from "@/sanity/types";
+import {
+ type Dispatch,
+ type RefObject,
+ type SetStateAction,
+ createContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+export const PlayerContext = createContext<{
+ podcast: PodcastQueryResult;
+ setPodcast: Dispatch>;
+ isOpen: boolean;
+ setIsOpen: Dispatch>;
+ isMinimized: boolean;
+ setIsMinimized: Dispatch>;
+ audio: {
+ id: number;
+ src: string;
+ title: string;
+ artist: string;
+ isPlaying: boolean;
+ curTime: number;
+ duration: number;
+ playbackRate: number;
+ };
+ setAudio: Dispatch<
+ SetStateAction<{
+ id: number;
+ src: string;
+ title: string;
+ artist: string;
+ isPlaying: boolean;
+ curTime: number;
+ duration: number;
+ playbackRate: number;
+ }>
+ >;
+ audioRef: RefObject | undefined;
+ volume: number;
+ setVolume: Dispatch>;
+}>({
+ podcast: null,
+ setPodcast: () => null,
+ isOpen: false,
+ setIsOpen: () => null,
+ isMinimized: false,
+ setIsMinimized: () => null,
+ audio: {
+ id: 1,
+ src: "",
+ title: "",
+ artist: "",
+ isPlaying: false,
+ curTime: 0,
+ duration: 100,
+ playbackRate: 1.0,
+ },
+ setAudio: () => null,
+ audioRef: undefined,
+ volume: 100,
+ setVolume: () => null,
+});
+
+export const PlayerProvider = ({ children }: { children: JSX.Element }) => {
+ const [podcast, setPodcast] = useState(null);
+ const [isOpen, setIsOpen] = useState(true);
+ const [isMinimized, setIsMinimized] = useState(false);
+ //TODO: make this recent
+ const [audio, setAudio] = useState({
+ id: 1,
+ src: "",
+ title: "",
+ artist: "",
+ isPlaying: false,
+ curTime: 0,
+ duration: 100,
+ playbackRate: 1.0,
+ });
+ const audioRef = useRef(null);
+ const [volume, setVolume] = useState(100);
+
+ useEffect(() => {
+ const src = podcast?.spotify?.enclosures?.at(0)?.url;
+ if (!src) {
+ return;
+ }
+ if (src === audio.src) {
+ return;
+ }
+ setAudio((p) => {
+ return { ...p, src };
+ });
+ setIsOpen(true);
+ }, [podcast, audio.src]);
+
+ useEffect(() => {
+ if (!audioRef.current) {
+ return;
+ }
+ audioRef.current.volume = volume / 100;
+ }, [volume]);
+
+ useEffect(() => {
+ if (audioRef?.current && podcast) {
+ setIsOpen(true);
+ }
+ }, [podcast]);
+
+ useEffect(() => {
+ if (audioRef.current) {
+ audioRef.current.playbackRate = audio.playbackRate;
+ }
+ }, [audio.playbackRate]);
+
+ useEffect(() => {
+ if (!audio?.src || audioRef.current?.src === audio.src) {
+ return;
+ }
+
+ audioRef.current = new Audio(audio.src);
+
+ // play and pause
+ audioRef.current.addEventListener("play", () => {
+ setAudio((p) => {
+ return { ...p, isPlaying: true };
+ });
+ });
+ audioRef.current.addEventListener("pause", () => {
+ setAudio((p) => {
+ return { ...p, isPlaying: false };
+ });
+ });
+
+ //lets trigger when audio is ready
+ audioRef.current.addEventListener("canplay", () => {
+ audioRef.current?.play();
+ });
+
+ // time and duration
+ audioRef.current.addEventListener("loadedmetadata", () => {
+ setAudio((p) => {
+ if (!audioRef?.current) {
+ return p;
+ }
+ return {
+ ...p,
+ curTime: audioRef.current.currentTime,
+ duration: audioRef.current.duration,
+ };
+ });
+ });
+ audioRef.current.addEventListener("timeupdate", () => {
+ setAudio((p) => {
+ if (!audioRef?.current) {
+ return p;
+ }
+ return {
+ ...p,
+ curTime: audioRef.current.currentTime,
+ };
+ });
+ });
+
+ return () => {
+ audioRef.current?.pause();
+ };
+ }, [audio.src]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/web/components/player-floating.tsx b/apps/web/components/player-floating.tsx
new file mode 100644
index 000000000..5d2c58774
--- /dev/null
+++ b/apps/web/components/player-floating.tsx
@@ -0,0 +1,189 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { Slider } from "@/components/ui/slider";
+import { useContext } from "react";
+import {
+ FaCirclePause,
+ FaPlay,
+ FaForward,
+ FaBackward,
+ FaVolumeHigh,
+ FaShare,
+ FaChevronDown,
+ FaX,
+ FaVolumeXmark,
+ FaChevronUp,
+} from "react-icons/fa6";
+import { PlayerContext } from "@/components/player-context";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import Image from "next/image";
+
+export default function PlayerFloating() {
+ const {
+ audioRef,
+ audio,
+ volume,
+ setVolume,
+ isOpen,
+ setIsOpen,
+ podcast,
+ setIsMinimized,
+ isMinimized,
+ } = useContext(PlayerContext);
+
+ const mute = () => {
+ setVolume(0);
+ };
+
+ const max = () => {
+ setVolume(100);
+ };
+
+ const close = () => {
+ setIsOpen(false);
+ if (audioRef?.current) {
+ audioRef.current?.pause();
+ }
+ };
+
+ return (
+ <>
+ {audioRef?.current && isOpen && podcast && (
+
+
+
{podcast.title}
+
+
+
+ {audio?.isPlaying ? (
+
+ ) : (
+
+ )}
+
+ {
+ audioRef.current!.currentTime = +val;
+ }}
+ />
+
+
+
+ {volume === 0 ? (
+
+ ) : (
+
+ )}
+ {
+ setVolume(+val);
+ }}
+ />
+
+
+ {isMinimized ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {!isMinimized && (
+
+
+ {podcast?.spotify?.itunes?.image?.href && (
+
+ )}
+
+
+
+ {podcast.title}
+
+
{podcast.excerpt}
+
+
+ )}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/player-play-button.tsx b/apps/web/components/player-play-button.tsx
new file mode 100644
index 000000000..afa380988
--- /dev/null
+++ b/apps/web/components/player-play-button.tsx
@@ -0,0 +1,54 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { useContext, useEffect } from "react";
+import { FaCirclePause, FaPlay } from "react-icons/fa6";
+import { PlayerContext } from "@/components/player-context";
+import type { PodcastQueryResult } from "@/sanity/types";
+
+export default function PlayerPlayButton({
+ podcast,
+}: {
+ podcast: NonNullable;
+}) {
+ const {
+ setPodcast,
+ audio,
+ audioRef,
+ setIsOpen,
+ podcast: currentPodcast,
+ } = useContext(PlayerContext);
+
+ const setCurrent = () => {
+ setIsOpen(true);
+ if (
+ audioRef?.current &&
+ podcast?.spotify?.enclosures?.at(0)?.url === audio.src
+ ) {
+ audioRef.current.play();
+ return;
+ }
+ setPodcast(() => podcast);
+ };
+
+ if (!audioRef) return null;
+
+ return (
+ <>
+ {currentPodcast?._id === podcast?._id && audio?.isPlaying ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/podcast-open-apple.tsx b/apps/web/components/podcast-open-apple.tsx
new file mode 100644
index 000000000..1a7ec63b4
--- /dev/null
+++ b/apps/web/components/podcast-open-apple.tsx
@@ -0,0 +1,20 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { PodcastQueryResult } from "@/sanity/types";
+import Link from "next/link";
+import { SiApplepodcasts } from "react-icons/si";
+
+export default function PodcastOpenApple() {
+ return (
+
+ );
+}
diff --git a/apps/web/components/podcast-open-spotify.tsx b/apps/web/components/podcast-open-spotify.tsx
new file mode 100644
index 000000000..fca67f578
--- /dev/null
+++ b/apps/web/components/podcast-open-spotify.tsx
@@ -0,0 +1,28 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { FaSpotify } from "react-icons/fa6";
+import type { PodcastQueryResult } from "@/sanity/types";
+import Link from "next/link";
+
+export default function PodcastOpenSpotify({
+ podcast,
+}: {
+ podcast: NonNullable;
+}) {
+ return (
+ <>
+ {podcast?._id && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/podcast-open-youtube.tsx b/apps/web/components/podcast-open-youtube.tsx
new file mode 100644
index 000000000..da6632dac
--- /dev/null
+++ b/apps/web/components/podcast-open-youtube.tsx
@@ -0,0 +1,28 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { FaYoutube } from "react-icons/fa6";
+import type { PodcastQueryResult } from "@/sanity/types";
+import Link from "next/link";
+
+export default function PodcastOpenYouTube({
+ podcast,
+}: {
+ podcast: NonNullable;
+}) {
+ return (
+ <>
+ {podcast?.youtube && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/podmatch-badge.tsx b/apps/web/components/podmatch-badge.tsx
new file mode 100644
index 000000000..99632bccc
--- /dev/null
+++ b/apps/web/components/podmatch-badge.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import Image from "next/image";
+
+export default function PodmatchBadge() {
+ return (
+
+ );
+}
diff --git a/apps/web/components/portable-text.tsx b/apps/web/components/portable-text.tsx
new file mode 100644
index 000000000..b00760573
--- /dev/null
+++ b/apps/web/components/portable-text.tsx
@@ -0,0 +1,97 @@
+/**
+ * This component uses Portable Text to render a post body.
+ *
+ * You can learn more about Portable Text on:
+ * https://www.sanity.io/docs/block-content
+ * https://github.com/portabletext/react-portabletext
+ * https://portabletext.org/
+ *
+ */
+
+import {
+ PortableText,
+ type PortableTextComponents,
+ type PortableTextBlock,
+} from "next-sanity";
+import Link from "next/link";
+import dynamic from "next/dynamic";
+
+const BlockImage = dynamic(() => import("@/components/block-image"));
+const BlockCode = dynamic(() => import("@/components/block-code"));
+const TwitterEmbed = dynamic(() => import("@/components/twitter-embed"));
+const CodePenEmbed = dynamic(() => import("@/components/codepen-embed"));
+const CodeSandboxEmbed = dynamic(() => import("./codesandbox-embed"));
+const HTMLEmbed = dynamic(() => import("@/components/html-embed"));
+const QuoteEmbed = dynamic(() => import("@/components/quote-embed"));
+const BlockTable = dynamic(() => import("@/components/block-table"));
+const BlockYoutube = dynamic(() =>
+ import("@/components/youtube").then((mod) => mod.YouTube),
+);
+const YouTubeShorts = dynamic(() => import("./youtube-shorts").then((mod) => mod.YouTubeShorts));
+
+// Defined at module scope so the object identity is stable across renders.
+// Recreating it on every render causes unnecessary re-renders.
+const components: PortableTextComponents = {
+ // TODO: make this more dynamic
+ types: {
+ image: ({ value }) => ,
+ code: ({ value }) => ,
+ codepen: ({ value }) => ,
+ codesandbox: ({ value }) => ,
+ youtube: ({ value }) => (
+
+
+
+ ),
+ youtubeShorts: ({ value }) => (
+
+
+
+ ),
+ twitter: ({ value }) => ,
+ htmlBlock: ({ value }) => ,
+ quote: ({ value }) => ,
+ table: ({ value }) => ,
+ },
+ block: {
+ h5: ({ children }) => (
+ {children}
+ ),
+ h6: ({ children }) => (
+ {children}
+ ),
+ },
+ marks: {
+ link: ({ children, value }) => {
+ const href = value?.href || "#";
+ // Only treat off-site links as external (new tab + noreferrer).
+ const isExternal = !href.startsWith("/");
+ return (
+
+ {children}
+
+ );
+ },
+ internalLink: ({ children, value }) => {
+ return {children};
+ },
+ },
+};
+
+export default function CustomPortableText({
+ className,
+ value,
+}: {
+ className?: string;
+ value: PortableTextBlock[];
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/pro-benefits.tsx b/apps/web/components/pro-benefits.tsx
new file mode 100644
index 000000000..30427af84
--- /dev/null
+++ b/apps/web/components/pro-benefits.tsx
@@ -0,0 +1,161 @@
+"use client";
+import { useEffect, useState } from "react";
+import GoPro from "./user-go-pro";
+import Link from "next/link";
+import CoverImage from "./cover-image";
+
+import { Button } from "./ui/button";
+import { useRouter, useSearchParams } from "next/navigation";
+
+export default function ProBenefits({
+ coverImage,
+}: {
+ coverImage: any;
+}) {
+ const [showGoPro, setShowGoPro] = useState(false);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const showSubscribe = searchParams.get("showSubscribe");
+
+ useEffect(() => {
+ if (showSubscribe) {
+ router.replace("/pro");
+ setShowGoPro(true);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const proButton = (
+
+ );
+
+ return (
+ <>
+ {showGoPro && }
+
+
+
+
+
+
+ Take Your Experience to the Next Level
+
+
+ Unlock premium benefits with our CodingCat.dev Pro plan,
+ including premium content, lifetime access, and personalized
+ support.
+
+
+
+ {proButton}
+ {/*
+ Learn More
+ */}
+
+
+
+
+
+
+
+
+
+
+
+ Unlock Exclusive Content
+
+
+ As a CodingCat.dev Pro member, you'll gain access to our
+ premium content library, covering topics like web development,
+ cloud architecture, and modern frameworks.
+
+
+ Explore Content
+
+
+
+
+ As a CodingCat.dev Pro member, you'll have access to all of
+ our premium content, ensuring you can learn at your own pace and
+ revisit materials whenever you need.
+
+
+
+
+
+
+
+
+
+
+ Personalized Support
+
+
+ Get Tailored Guidance
+
+
+ As a CodingCat.dev Pro member, you'll have access to our
+ team of expert instructors who can provide personalized support
+ and feedback to help you achieve your learning goals.
+
+
+ Contact Support
+
+
+
+
+ Pricing
+
+
+ Affordable Plans
+
+
+
+
+ Monthly
+ $29
+
+
+ Billed monthly, cancel anytime.
+
+
+
+
+ Yearly
+ $199
+
+
+ Save $149, compared to monthly
+
+
+
+ {proButton}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/components/quote-embed.tsx b/apps/web/components/quote-embed.tsx
new file mode 100644
index 000000000..34e4ddd5c
--- /dev/null
+++ b/apps/web/components/quote-embed.tsx
@@ -0,0 +1,34 @@
+import { PortableText, type PortableTextComponents } from "next-sanity";
+import Link from "next/link";
+
+export default function QuoteEmbed(props: any) {
+ const { content, url } = props;
+ if (!content) {
+ return null;
+ }
+ const components: PortableTextComponents = {
+ marks: {
+ link: ({ children, value }) => {
+ return (
+
+ {children}
+
+ );
+ },
+ internalLink: ({ children, value }) => {
+ return {children};
+ },
+ },
+ };
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/recent-activity.tsx b/apps/web/components/recent-activity.tsx
new file mode 100644
index 000000000..6c6e13920
--- /dev/null
+++ b/apps/web/components/recent-activity.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Lightbulb, FileVideo, Handshake, Clock, Loader2 } from "lucide-react";
+import { POLL_INTERVAL_MS, type ActivityItem } from "@/lib/types/dashboard";
+
+const typeConfig: Record<
+ string,
+ { icon: typeof Lightbulb; label: string; color: string }
+> = {
+ contentIdea: {
+ icon: Lightbulb,
+ label: "Idea",
+ color: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
+ },
+ automatedVideo: {
+ icon: FileVideo,
+ label: "Video",
+ color: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
+ },
+ sponsorLead: {
+ icon: Handshake,
+ label: "Sponsor",
+ color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
+ },
+};
+
+function formatTimeAgo(dateString: string) {
+ const seconds = Math.floor(
+ (Date.now() - new Date(dateString).getTime()) / 1000,
+ );
+ if (seconds < 60) return "just now";
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+}
+
+export function RecentActivity() {
+ const [items, setItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const fetchActivity = useCallback(async () => {
+ try {
+ const res = await fetch("/api/dashboard/activity");
+ if (res.ok) {
+ const data = await res.json();
+ setItems(data);
+ }
+ } catch (error) {
+ console.error("Failed to fetch activity:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchActivity();
+ const interval = setInterval(fetchActivity, POLL_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, [fetchActivity]);
+
+ return (
+
+
Recent Activity
+ {isLoading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+ No activity yet \u2014 content will appear here as the pipeline runs.
+
+ ) : (
+
+ {items.map((item) => {
+ const config = typeConfig[item._type] ?? typeConfig.contentIdea;
+ const Icon = config.icon;
+ const name = item.title || item.companyName || "Untitled";
+ return (
+
+
+
+
{name}
+
+
+ {config.label}
+
+ {item.status && (
+ {item.status}
+ )}
+
+
+
+
+ {formatTimeAgo(item._updatedAt)}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/section-cards-live.tsx b/apps/web/components/section-cards-live.tsx
new file mode 100644
index 000000000..d1a2e92f4
--- /dev/null
+++ b/apps/web/components/section-cards-live.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { FileVideo, Flag, Handshake, DollarSign, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { POLL_INTERVAL_MS, type DashboardMetrics } from "@/lib/types/dashboard";
+
+export function SectionCardsLive({
+ initialMetrics,
+}: {
+ initialMetrics?: DashboardMetrics;
+}) {
+ const [metrics, setMetrics] = useState(
+ initialMetrics ?? {
+ videosPublished: 0,
+ flaggedForReview: 0,
+ sponsorPipeline: 0,
+ revenue: null,
+ },
+ );
+ const [lastUpdated, setLastUpdated] = useState(new Date());
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [secondsAgo, setSecondsAgo] = useState(0);
+
+ const fetchMetrics = useCallback(async () => {
+ try {
+ setIsRefreshing(true);
+ const res = await fetch("/api/dashboard/metrics");
+ if (res.ok) {
+ const data = await res.json();
+ setMetrics(data);
+ setLastUpdated(new Date());
+ setSecondsAgo(0);
+ }
+ } catch (error) {
+ console.error("Failed to refresh metrics:", error);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchMetrics();
+ const interval = setInterval(fetchMetrics, POLL_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, [fetchMetrics]);
+
+ useEffect(() => {
+ const tick = setInterval(() => {
+ setSecondsAgo(Math.floor((Date.now() - lastUpdated.getTime()) / 1000));
+ }, 1000);
+ return () => clearInterval(tick);
+ }, [lastUpdated]);
+
+ const timeAgo =
+ secondsAgo < 5
+ ? "just now"
+ : secondsAgo < 60
+ ? `${secondsAgo}s ago`
+ : `${Math.floor(secondsAgo / 60)}m ago`;
+
+ const cards = [
+ {
+ title: "Videos Published",
+ value: String(metrics.videosPublished),
+ description: "Total automated videos published",
+ icon: FileVideo,
+ },
+ {
+ title: "Flagged for Review",
+ value: String(metrics.flaggedForReview),
+ description: "Content needing attention",
+ icon: Flag,
+ },
+ {
+ title: "Sponsor Pipeline",
+ value: String(metrics.sponsorPipeline),
+ description: "Active sponsor leads",
+ icon: Handshake,
+ },
+ {
+ title: "Revenue",
+ value: metrics.revenue != null ? `$${metrics.revenue}` : "\u2014",
+ description: "Monthly sponsor revenue",
+ icon: DollarSign,
+ },
+ ];
+
+ return (
+
+
+
Updated {timeAgo}
+
+
+
+ {cards.map((card) => (
+
+
+
+ {card.title}
+
+
+
+
+ {card.value}
+ {card.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/components/section-cards.tsx b/apps/web/components/section-cards.tsx
new file mode 100644
index 000000000..bd2773119
--- /dev/null
+++ b/apps/web/components/section-cards.tsx
@@ -0,0 +1,94 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { FileVideo, Flag, Handshake, DollarSign } from "lucide-react";
+import { dashboardQuery } from "@/lib/sanity/dashboard";
+
+async function fetchDashboardMetrics() {
+ try {
+ const [videosPublished, flaggedVideos, newIdeas, sponsorPipeline] =
+ await Promise.all([
+ dashboardQuery(
+ `count(*[_type == "automatedVideo" && status == "published"])`,
+ ),
+ dashboardQuery(
+ `count(*[_type == "automatedVideo" && status == "flagged"])`,
+ ),
+ dashboardQuery(
+ `count(*[_type == "contentIdea" && status == "new"])`,
+ ),
+ dashboardQuery(
+ `count(*[_type == "sponsorLead" && status != "paid"])`,
+ ),
+ ]);
+
+ return {
+ videosPublished: String(videosPublished ?? 0),
+ flaggedForReview: String((flaggedVideos ?? 0) + (newIdeas ?? 0)),
+ sponsorPipeline: String(sponsorPipeline ?? 0),
+ revenue: "\u2014",
+ };
+ } catch (error) {
+ console.error("Failed to fetch dashboard metrics:", error);
+ return {
+ videosPublished: "\u2014",
+ flaggedForReview: "\u2014",
+ sponsorPipeline: "\u2014",
+ revenue: "\u2014",
+ };
+ }
+}
+
+export async function SectionCards() {
+ const metrics = await fetchDashboardMetrics();
+
+ const cards = [
+ {
+ title: "Videos Published",
+ value: metrics.videosPublished,
+ description: "Total automated videos published",
+ icon: FileVideo,
+ },
+ {
+ title: "Flagged for Review",
+ value: metrics.flaggedForReview,
+ description: "Content needing attention",
+ icon: Flag,
+ },
+ {
+ title: "Sponsor Pipeline",
+ value: metrics.sponsorPipeline,
+ description: "Active sponsor leads",
+ icon: Handshake,
+ },
+ {
+ title: "Revenue",
+ value: metrics.revenue,
+ description: "Monthly sponsor revenue",
+ icon: DollarSign,
+ },
+ ];
+
+ return (
+
+ {cards.map((card) => (
+
+
+
+ {card.title}
+
+
+
+
+ {card.value}
+ {card.description}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/components/site-header.tsx b/apps/web/components/site-header.tsx
new file mode 100644
index 000000000..d357d0f91
--- /dev/null
+++ b/apps/web/components/site-header.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { usePathname } from "next/navigation"
+import { Separator } from "@/components/ui/separator"
+import { SidebarTrigger } from "@/components/ui/sidebar"
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb"
+
+function getBreadcrumbs(pathname: string) {
+ const segments = pathname.split("/").filter(Boolean)
+ return segments.map((segment, index) => {
+ const href = "/" + segments.slice(0, index + 1).join("/")
+ const label = segment.charAt(0).toUpperCase() + segment.slice(1)
+ const isLast = index === segments.length - 1
+ return { href, label, isLast }
+ })
+}
+
+export function SiteHeader() {
+ const pathname = usePathname()
+ const breadcrumbs = getBreadcrumbs(pathname)
+
+ return (
+
+
+
+
+
+ {breadcrumbs.map((crumb, index) => (
+
+ {index > 0 && }
+
+ {crumb.isLast ? (
+ {crumb.label}
+ ) : (
+
+ {crumb.label}
+
+ )}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/components/sponsor-card.tsx b/apps/web/components/sponsor-card.tsx
new file mode 100644
index 000000000..22c81e76b
--- /dev/null
+++ b/apps/web/components/sponsor-card.tsx
@@ -0,0 +1,52 @@
+import type { PodcastQueryResult } from "@/sanity/types";
+import Link, { LinkProps } from "next/link";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+} from "@/components/ui/card";
+import CoverImage from "@/components/cover-image";
+
+export default function SponsorCard({
+ sponsors,
+}: {
+ sponsors: NonNullable["sponsor"];
+}) {
+ if (!sponsors?.length) return <>>;
+
+ return (
+
+ {sponsors?.map((sponsor) => {
+ const { slug, _id, title, excerpt, coverImage, url } = sponsor;
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx
new file mode 100644
index 000000000..bae99955c
--- /dev/null
+++ b/apps/web/components/theme-provider.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import * as React from "react";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import type { ThemeProviderProps } from "next-themes";
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children};
+}
diff --git a/apps/web/components/twitter-embed.tsx b/apps/web/components/twitter-embed.tsx
new file mode 100644
index 000000000..8c9e9f8e9
--- /dev/null
+++ b/apps/web/components/twitter-embed.tsx
@@ -0,0 +1,10 @@
+"use client";
+import { TwitterTweetEmbed } from "react-twitter-embed";
+
+export default function TwitterEmbed(props: any) {
+ const { id } = props;
+ if (!id) {
+ return Add Twitter (X) id
;
+ }
+ return ;
+}
diff --git a/apps/web/components/ui/accordion.tsx b/apps/web/components/ui/accordion.tsx
new file mode 100644
index 000000000..b310bab75
--- /dev/null
+++ b/apps/web/components/ui/accordion.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx
new file mode 100644
index 000000000..f9ce27e87
--- /dev/null
+++ b/apps/web/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx
new file mode 100644
index 000000000..ee12747d1
--- /dev/null
+++ b/apps/web/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/apps/web/components/ui/aspect-ratio.tsx b/apps/web/components/ui/aspect-ratio.tsx
new file mode 100644
index 000000000..359bc940d
--- /dev/null
+++ b/apps/web/components/ui/aspect-ratio.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
+
+const AspectRatio = AspectRatioPrimitive.Root;
+
+export { AspectRatio };
diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx
new file mode 100644
index 000000000..d9f1b10cf
--- /dev/null
+++ b/apps/web/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx
new file mode 100644
index 000000000..b5a6bd103
--- /dev/null
+++ b/apps/web/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+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 rounded-md border px-2.5 py-0.5 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 shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/web/components/ui/breadcrumb.tsx b/apps/web/components/ui/breadcrumb.tsx
new file mode 100644
index 000000000..a35685a91
--- /dev/null
+++ b/apps/web/components/ui/breadcrumb.tsx
@@ -0,0 +1,115 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode;
+ }
+>(({ ...props }, ref) => );
+Breadcrumb.displayName = "Breadcrumb";
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbList.displayName = "BreadcrumbList";
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbItem.displayName = "BreadcrumbItem";
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+});
+BreadcrumbLink.displayName = "BreadcrumbLink";
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbPage.displayName = "BreadcrumbPage";
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:w-3.5 [&>svg]:h-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+);
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+);
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx
new file mode 100644
index 000000000..714ffea01
--- /dev/null
+++ b/apps/web/components/ui/button.tsx
@@ -0,0 +1,57 @@
+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-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/apps/web/components/ui/calendar.tsx b/apps/web/components/ui/calendar.tsx
new file mode 100644
index 000000000..febea376e
--- /dev/null
+++ b/apps/web/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import * as React from "react";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react";
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
+
+import { cn } from "@/lib/utils";
+import { Button, buttonVariants } from "@/components/ui/button";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months,
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ "bg-popover absolute inset-0 opacity-0",
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
+ defaultClassNames.caption_label,
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+ defaultClassNames.weekday,
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ "text-muted-foreground select-none text-[0.8rem]",
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ "bg-accent rounded-l-md",
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled,
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ );
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ );
+ }
+
+ if (orientation === "right") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+