Skip to content

Commit b8f256a

Browse files
authored
release: NotebookLM TS client, trend discovery, dashboard auth, scene types, sponsor bridge, RSS fixes (#601)
feat: NotebookLM research pipeline + infographic generation
2 parents d087c39 + ec1ff93 commit b8f256a

File tree

23 files changed

+2070
-180
lines changed

23 files changed

+2070
-180
lines changed

app/(dashboard)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AppSidebar } from "@/components/app-sidebar";
88
import { SiteHeader } from "@/components/site-header";
99
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
1010
import { Toaster } from "@/components/ui/sonner";
11+
import { SiteAnalytics } from "@/components/analytics";
1112

1213
const nunito = Nunito({
1314
subsets: ["latin"],
@@ -83,6 +84,7 @@ export default async function DashboardLayout({
8384
)}
8485
<Toaster />
8586
</ThemeProvider>
87+
<SiteAnalytics />
8688
</body>
8789
</html>
8890
);

app/(main)/(course)/courses/rss.xml/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function GET() {
99
});
1010
return new Response(feed.rss2(), {
1111
headers: {
12-
"content-type": "text/xml",
12+
"content-type": "application/rss+xml; charset=utf-8",
1313
"cache-control": "max-age=0, s-maxage=3600",
1414
},
1515
});

app/(main)/(podcast)/podcasts/rss.xml/route.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
export const dynamic = "force-dynamic"; // defaults to auto
22

3-
import { buildFeed } from "@/lib/rss";
4-
import { ContentType } from "@/lib/types";
3+
import { buildPodcastFeed } from "@/lib/rss";
54

65
export async function GET() {
7-
const feed = await buildFeed({
8-
type: ContentType.podcast,
9-
});
10-
return new Response(feed.rss2(), {
6+
const xml = await buildPodcastFeed({});
7+
return new Response(xml, {
118
headers: {
12-
"content-type": "text/xml",
9+
"content-type": "application/rss+xml; charset=utf-8",
1310
"cache-control": "max-age=0, s-maxage=3600",
1411
},
1512
});

app/(main)/(post)/blog/rss.xml/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function GET() {
99
});
1010
return new Response(feed.rss2(), {
1111
headers: {
12-
"content-type": "text/xml",
12+
"content-type": "application/rss+xml; charset=utf-8",
1313
"cache-control": "max-age=0, s-maxage=3600",
1414
},
1515
});

app/(main)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { toPlainText } from "next-sanity";
3434
import { VisualEditing } from "next-sanity/visual-editing";
3535
import { DisableDraftMode } from "@/components/disable-draft-mode";
3636
import { ModeToggle } from "@/components/mode-toggle";
37+
import { SiteAnalytics } from "@/components/analytics";
3738

3839
const nunito = Nunito({
3940
subsets: ["latin"],
@@ -156,6 +157,7 @@ export default async function RootLayout({
156157
</ThemeProvider>
157158
</PlayerProvider>
158159
</CookiesProviderClient>
160+
<SiteAnalytics />
159161
</body>
160162
</html>
161163
);

app/api/cron/ingest/route.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const FALLBACK_TRENDS: TrendResult[] = [
100100
slug: "webassembly-web-apps",
101101
score: 60,
102102
signals: [{ source: "blog", title: "WebAssembly", url: "https://webassembly.org/", score: 60 }],
103-
whyTrending: "WASM adoption growing in production apps",
103+
whyTrending: "WASM adoption growing in [REDACTED SECRET: NEXT_PUBLIC_SANITY_DATASET] apps",
104104
suggestedAngle: "Real-world use cases where WASM outperforms JS",
105105
},
106106
];
@@ -109,8 +109,19 @@ const FALLBACK_TRENDS: TrendResult[] = [
109109
// Gemini Script Generation
110110
// ---------------------------------------------------------------------------
111111

112-
const SYSTEM_INSTRUCTION =
113-
"You are a content strategist for CodingCat.dev, a web development education channel. You create engaging, Cleo Abram-style explainer video scripts that are educational, energetic, and concise (60-90 seconds).";
112+
const SYSTEM_INSTRUCTION = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
113+
114+
Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles:
115+
- Start with a BOLD claim or surprising fact that makes people stop scrolling
116+
- Use analogies and real-world comparisons to explain technical concepts
117+
- Build tension: "Here's the problem... here's why it matters... here's the breakthrough"
118+
- Keep energy HIGH — short sentences, active voice, conversational tone
119+
- End with a clear takeaway that makes the viewer feel smarter
120+
- Target audience: developers who want to stay current but don't have time to read everything
121+
122+
Script format: 60-90 second explainer videos. Think TikTok/YouTube Shorts energy with real educational depth.
123+
124+
CodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.`;
114125

115126
function buildPrompt(trends: TrendResult[], research?: ResearchPayload): string {
116127
const topicList = trends
@@ -152,8 +163,8 @@ function buildPrompt(trends: TrendResult[], research?: ResearchPayload): string
152163
}
153164
}
154165

155-
if (research.infographicUrl) {
156-
researchContext += `\n### Infographic Available\nAn infographic has been generated for this topic. Use sceneType "narration" with bRollUrl pointing to the infographic for at least one scene.\n`;
166+
if (research.infographicUrls && research.infographicUrls.length > 0) {
167+
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`;
157168
}
158169
}
159170

@@ -403,7 +414,12 @@ export async function GET(request: NextRequest) {
403414
if (process.env.ENABLE_NOTEBOOKLM_RESEARCH === "true") {
404415
console.log(`[CRON/ingest] Conducting research on: "${trends[0].topic}"...`);
405416
try {
406-
research = await conductResearch(trends[0].topic);
417+
// Extract source URLs from trend signals to seed the notebook
418+
const sourceUrls = (trends[0].signals ?? [])
419+
.map((s: { url?: string }) => s.url)
420+
.filter((u): u is string => !!u && u.startsWith("http"))
421+
.slice(0, 5);
422+
research = await conductResearch(trends[0].topic, { sourceUrls });
407423
console.log(`[CRON/ingest] Research complete: ${research.sources.length} sources, ${research.sceneHints.length} scene hints`);
408424
} catch (err) {
409425
console.warn("[CRON/ingest] Research failed, continuing without:", err);

app/api/webhooks/stripe-sponsor/route.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from 'next/server'
22
import Stripe from 'stripe'
33
import { sanityWriteClient } from '@/lib/sanity-write-client'
4+
import { bridgeSponsorLeadToSponsor } from '@/lib/sponsor/sponsor-bridge'
45

56
/**
67
* Stripe webhook handler for sponsor invoices.
@@ -69,6 +70,12 @@ export async function POST(request: Request) {
6970
break
7071
}
7172

73+
// Idempotency guard — skip if already paid (Stripe retries on 5xx)
74+
if (lead.status === 'paid') {
75+
console.log('[SPONSOR] Lead already paid, skipping (idempotent):', lead._id)
76+
break
77+
}
78+
7279
// Update status to 'paid'
7380
await sanityWriteClient
7481
.patch(lead._id)
@@ -80,9 +87,18 @@ export async function POST(request: Request) {
8087

8188
console.log('[SPONSOR] Updated sponsorLead to paid:', lead._id)
8289

90+
// Bridge: create/link sponsor doc for content attribution
91+
try {
92+
await bridgeSponsorLeadToSponsor(lead._id)
93+
console.log('[SPONSOR] Sponsor bridge completed for lead:', lead._id)
94+
} catch (bridgeError) {
95+
// Non-fatal — don't fail the webhook if bridge fails
96+
console.error('[SPONSOR] Sponsor bridge failed (non-fatal):', bridgeError)
97+
}
98+
8399
// Find next available automatedVideo (status script_ready or later, no sponsorSlot assigned)
84100
const availableVideo = await sanityWriteClient.fetch(
85-
`*[_type == "automatedVideo" && status in ["script_ready", "media_ready", "ready_to_publish"] && !defined(bookedSlot)][0]{
101+
`*[_type == "automatedVideo" && status in ["script_ready", "media_ready", "ready_to_publish"] && !defined(bookedSlot)] | order(_createdAt asc) [0]{
86102
_id,
87103
title,
88104
status

app/api/youtube/rss.xml/route.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ export async function GET() {
6363
updated: new Date(),
6464
generator: "Next.js using Feed for Node.js",
6565
feedLinks: {
66-
json: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed`,
67-
atom: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed?format=atom`,
66+
rss2: `${process.env.NEXT_PUBLIC_BASE_URL || "https://codingcat.dev"}/api/youtube/rss.xml`,
6867
},
6968
});
7069

@@ -93,7 +92,7 @@ export async function GET() {
9392

9493
return new Response(feed.rss2(), {
9594
headers: {
96-
"content-type": "text/xml",
95+
"content-type": "application/rss+xml; charset=utf-8",
9796
"cache-control": "max-age=0, s-maxage=3600",
9897
},
9998
});

components/analytics.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use client";
2+
3+
import { Analytics } from "@vercel/analytics/next";
4+
import { SpeedInsights } from "@vercel/speed-insights/next";
5+
import Script from "next/script";
6+
7+
export function SiteAnalytics() {
8+
return (
9+
<>
10+
<Analytics />
11+
<SpeedInsights />
12+
{process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
13+
<Script
14+
src={process.env.NEXT_PUBLIC_UMAMI_URL || "https://analytics.codingcat.dev/script.js"}
15+
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
16+
strategy="afterInteractive"
17+
/>
18+
)}
19+
</>
20+
);
21+
}

docs/umami-setup.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Umami Analytics Setup
2+
3+
## Overview
4+
Umami is self-hosted on Vercel + Supabase for $0/month analytics.
5+
6+
## Setup Steps
7+
8+
### 1. Fork Umami
9+
Fork https://github.com/umami-is/umami to the CodingCatDev GitHub org.
10+
11+
### 2. Create Umami tables in Supabase
12+
Run the Umami PostgreSQL schema in your Supabase SQL editor.
13+
See: https://umami.is/docs/install
14+
15+
### 3. Deploy to Vercel
16+
- Import the forked repo in Vercel
17+
- Set environment variable: `DATABASE_URL` = your Supabase connection string
18+
- Deploy
19+
20+
### 4. Configure
21+
- Visit your Umami instance (e.g., analytics.codingcat.dev)
22+
- Create a website entry for codingcat.dev
23+
- Copy the Website ID
24+
25+
### 5. Set environment variables in codingcat.dev
26+
```
27+
NEXT_PUBLIC_UMAMI_WEBSITE_ID=<your-website-id>
28+
NEXT_PUBLIC_UMAMI_URL=https://analytics.codingcat.dev/script.js
29+
```
30+
31+
### 6. Custom domain (optional)
32+
Add `analytics.codingcat.dev` as a custom domain in Vercel for the Umami project.
33+
34+
## Querying Analytics Data
35+
Since Umami writes to your Supabase database, you can query analytics directly:
36+
37+
```sql
38+
-- Top pages last 30 days
39+
SELECT url_path, COUNT(*) as views
40+
FROM website_event
41+
WHERE website_id = '<your-id>'
42+
AND created_at > NOW() - INTERVAL '30 days'
43+
AND event_type = 1
44+
GROUP BY url_path
45+
ORDER BY views DESC
46+
LIMIT 20;
47+
48+
-- Sponsor report: views by content type
49+
SELECT
50+
CASE
51+
WHEN url_path LIKE '/post/%' THEN 'Blog Post'
52+
WHEN url_path LIKE '/podcast/%' THEN 'Podcast'
53+
WHEN url_path LIKE '/course/%' THEN 'Course'
54+
ELSE 'Other'
55+
END as content_type,
56+
COUNT(*) as views,
57+
COUNT(DISTINCT session_id) as unique_visitors
58+
FROM website_event
59+
WHERE website_id = '<your-id>'
60+
AND created_at > NOW() - INTERVAL '30 days'
61+
AND event_type = 1
62+
GROUP BY content_type
63+
ORDER BY views DESC;
64+
```

0 commit comments

Comments
 (0)