with lh-condensed class
+ const h2Match = block.match(
+ /]*class="[^"]*lh-condensed[^"]*"[^>]*>[\s\S]*?]*href="([^"]+)"[^>]*>/,
+ );
+ const repoPath = h2Match?.[1]?.trim();
+ if (!repoPath) continue;
+
+ const name = repoPath.replace(/^\//, "");
+ const url = `https://github.com${repoPath}`;
+
+ // Description from
+ const descMatch = block.match(
+ /
]*class="[^"]*col-9[^"]*"[^>]*>([\s\S]*?)<\/p>/,
+ );
+ const description = (descMatch?.[1] ?? "")
+ .replace(/<[^>]+>/g, "")
+ .trim();
+
+ // Stars today from "N stars today" text
+ const starsMatch = block.match(
+ /(\d[\d,]*)\s+stars?\s+today/i,
+ );
+ const starsToday = starsMatch
+ ? parseInt(starsMatch[1].replace(/,/g, ""), 10)
+ : 0;
+
+ repos.push({
+ name,
+ description,
+ url,
+ starsToday,
+ language,
+ });
+ }
+
+ return repos;
+}
+
+/**
+ * Fetch trending repos from GitHub for multiple languages.
+ */
+async function fetchGitHubTrending(): Promise {
+ const signals: TrendSignal[] = [];
+ try {
+ const results = await Promise.allSettled(
+ GITHUB_TRENDING_LANGS.map(async (lang) => {
+ const res = await fetchWithTimeout(
+ `https://github.com/trending/${lang}?since=weekly`,
+ {
+ headers: {
+ "User-Agent": "Mozilla/5.0 (compatible; TrendDiscovery/1.0)",
+ Accept: "text/html",
+ },
+ },
+ );
+ if (!res.ok) {
+ console.warn(
+ `${LOG_PREFIX} GitHub trending ${lang} failed: ${res.status}`,
+ );
+ return [];
+ }
+ const html = await res.text();
+ return parseGitHubTrendingHTML(html, lang);
+ }),
+ );
+
+ const seen = new Set();
+ for (const result of results) {
+ if (result.status !== "fulfilled") continue;
+ for (const repo of result.value) {
+ if (seen.has(repo.url)) continue;
+ seen.add(repo.url);
+
+ const text = `${repo.name} ${repo.description}`;
+ const relevance = computeRelevance(text);
+
+ // Filter by web dev / AI relevance
+ if (relevance <= 0.1) continue;
+
+ signals.push({
+ source: "github",
+ title: `${repo.name}: ${repo.description || "Trending repository"}`,
+ url: repo.url,
+ score: repo.starsToday * relevance * 1.5,
+ metadata: {
+ starsToday: repo.starsToday,
+ language: repo.language,
+ repoName: repo.name,
+ relevance,
+ },
+ });
+ }
+ }
+
+ console.log(`${LOG_PREFIX} GitHub: ${signals.length} trending repos`);
+ } catch (err) {
+ console.warn(`${LOG_PREFIX} GitHub trending error:`, err);
+ }
+ return signals;
+}
+
+// ---------------------------------------------------------------------------
+// Deduplication & merging
+// ---------------------------------------------------------------------------
+
+interface SignalGroup {
+ topic: string;
+ signals: TrendSignal[];
+ rawScore: number;
+}
+
+/**
+ * Normalize a title for comparison: lowercase, strip punctuation.
+ */
+function normalizeTitle(title: string): string {
+ return title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/**
+ * Check if two signals should be merged based on shared significant words.
+ * Merge if 3+ shared significant words AND 50%+ overlap.
+ */
+function shouldMerge(a: string, b: string): boolean {
+ const wordsA = significantWords(a);
+ const wordsB = significantWords(b);
+
+ if (wordsA.length === 0 || wordsB.length === 0) return false;
+
+ const setA = new Set(wordsA);
+ const setB = new Set(wordsB);
+ let shared = 0;
+ for (const w of setA) {
+ if (setB.has(w)) shared++;
+ }
+
+ if (shared < 3) return false;
+
+ const minSize = Math.min(setA.size, setB.size);
+ return shared / minSize >= 0.5;
+}
+
+/**
+ * Group signals into deduplicated topic clusters.
+ */
+function deduplicateSignals(allSignals: TrendSignal[]): SignalGroup[] {
+ const groups: SignalGroup[] = [];
+
+ for (const signal of allSignals) {
+ const normTitle = normalizeTitle(signal.title);
+ let merged = false;
+
+ for (const group of groups) {
+ if (shouldMerge(normTitle, normalizeTitle(group.topic))) {
+ group.signals.push(signal);
+ group.rawScore += signal.score;
+ merged = true;
+ break;
+ }
+ }
+
+ if (!merged) {
+ groups.push({
+ topic: signal.title,
+ signals: [signal],
+ rawScore: signal.score,
+ });
+ }
+ }
+
+ return groups;
+}
+
+// ---------------------------------------------------------------------------
+// Scoring & result generation
+// ---------------------------------------------------------------------------
+
+/**
+ * Generate a human-readable "why trending" explanation.
+ */
+function generateWhyTrending(group: SignalGroup): string {
+ const sources = [...new Set(group.signals.map((s) => s.source))];
+ const sourceNames: Record = {
+ hackernews: "Hacker News",
+ devto: "Dev.to",
+ blog: "tech blogs",
+ youtube: "YouTube",
+ github: "GitHub Trending",
+ };
+
+ const parts: string[] = [];
+ parts.push(
+ `Appeared in ${group.signals.length} signal(s) across ${sources.map((s) => sourceNames[s]).join(", ")}`,
+ );
+
+ const hnSignals = group.signals.filter((s) => s.source === "hackernews");
+ if (hnSignals.length > 0) {
+ const maxScore = Math.max(
+ ...hnSignals.map((s) => (s.metadata?.hnScore as number) ?? 0),
+ );
+ if (maxScore > 100) {
+ parts.push(`HN score up to ${maxScore}`);
+ }
+ }
+
+ const ghSignals = group.signals.filter((s) => s.source === "github");
+ if (ghSignals.length > 0) {
+ const totalStars = ghSignals.reduce(
+ (sum, s) => sum + ((s.metadata?.starsToday as number) ?? 0),
+ 0,
+ );
+ if (totalStars > 0) {
+ parts.push(`${totalStars} GitHub stars today`);
+ }
+ }
+
+ return parts.join(". ") + ".";
+}
+
+/**
+ * Generate a suggested content angle for the topic.
+ */
+function generateSuggestedAngle(group: SignalGroup): string {
+ const topic = group.topic;
+ const sources = [...new Set(group.signals.map((s) => s.source))];
+
+ if (sources.includes("github") && sources.length > 1) {
+ return `Deep-dive into ${topic} — explore the trending repo and explain why developers are excited.`;
+ }
+ if (sources.includes("hackernews") && sources.includes("devto")) {
+ return `Community spotlight: ${topic} is generating discussion on both HN and Dev.to — compare perspectives.`;
+ }
+ if (sources.includes("blog")) {
+ return `Official announcement breakdown: explain what ${topic} means for web developers.`;
+ }
+ return `Explainer video: what is ${topic} and why should web developers care?`;
+}
+
+/**
+ * Apply cross-source boost and normalize scores to 0–100.
+ */
+function scoreAndRank(
+ groups: SignalGroup[],
+ maxTopics: number,
+): TrendResult[] {
+ // Apply cross-source boost
+ for (const group of groups) {
+ const uniqueSources = new Set(group.signals.map((s) => s.source)).size;
+ if (uniqueSources >= 3) {
+ group.rawScore *= 1.5;
+ } else if (uniqueSources >= 2) {
+ group.rawScore *= 1.3;
+ }
+ }
+
+ // Find max score for normalization
+ const maxRaw = Math.max(...groups.map((g) => g.rawScore), 1);
+
+ // Build results, normalize to 0–100
+ const results: TrendResult[] = groups.map((group) => ({
+ topic: group.topic,
+ slug: slugify(group.topic),
+ score: Math.round((group.rawScore / maxRaw) * 100),
+ signals: group.signals,
+ whyTrending: generateWhyTrending(group),
+ suggestedAngle: generateSuggestedAngle(group),
+ }));
+
+ // Sort by score descending
+ results.sort((a, b) => b.score - a.score);
+
+ return results.slice(0, maxTopics);
+}
+
+// ---------------------------------------------------------------------------
+// Main entry point
+// ---------------------------------------------------------------------------
+
+/**
+ * Discover trending web-dev and AI topics from multiple sources.
+ *
+ * Fetches signals from Hacker News, Dev.to, blog RSS feeds, YouTube, and
+ * GitHub Trending in parallel, deduplicates them, and returns scored results.
+ *
+ * @param config - Optional configuration overrides
+ * @returns Sorted array of trending topics with scores and signals
+ *
+ * @example
+ * ```ts
+ * import { discoverTrends } from "@/lib/services/trend-discovery";
+ *
+ * const trends = await discoverTrends({ maxTopics: 5 });
+ * for (const t of trends) {
+ * console.log(`${t.topic} (score: ${t.score}) — ${t.whyTrending}`);
+ * }
+ * ```
+ */
+export async function discoverTrends(
+ config?: TrendDiscoveryConfig,
+): Promise {
+ const lookbackDays = config?.lookbackDays ?? 7;
+ const maxTopics = config?.maxTopics ?? 10;
+ const youtubeApiKey = config?.youtubeApiKey;
+
+ console.log(
+ `${LOG_PREFIX} Starting trend discovery (lookback=${lookbackDays}d, max=${maxTopics})`,
+ );
+
+ // Fetch all sources in parallel
+ const [hnResult, devtoResult, blogResult, ytResult, ghResult] =
+ await Promise.allSettled([
+ fetchHackerNews(lookbackDays),
+ fetchDevto(lookbackDays),
+ fetchBlogFeeds(lookbackDays),
+ fetchYouTube(lookbackDays, youtubeApiKey),
+ fetchGitHubTrending(),
+ ]);
+
+ // Collect all signals
+ const allSignals: TrendSignal[] = [];
+ const sourceResults = [hnResult, devtoResult, blogResult, ytResult, ghResult];
+ const sourceNames = ["HN", "Dev.to", "Blogs", "YouTube", "GitHub"];
+
+ for (let i = 0; i < sourceResults.length; i++) {
+ const result = sourceResults[i];
+ if (result.status === "fulfilled") {
+ allSignals.push(...result.value);
+ } else {
+ console.warn(
+ `${LOG_PREFIX} ${sourceNames[i]} source failed:`,
+ result.reason,
+ );
+ }
+ }
+
+ console.log(`${LOG_PREFIX} Total signals collected: ${allSignals.length}`);
+
+ if (allSignals.length === 0) {
+ console.warn(`${LOG_PREFIX} No signals found from any source`);
+ return [];
+ }
+
+ // Deduplicate and group
+ const groups = deduplicateSignals(allSignals);
+ console.log(`${LOG_PREFIX} Deduplicated into ${groups.length} topic groups`);
+
+ // Score, rank, and return
+ const results = scoreAndRank(groups, maxTopics);
+ console.log(
+ `${LOG_PREFIX} Returning ${results.length} trending topics (top score: ${results[0]?.score ?? 0})`,
+ );
+
+ return results;
+}
From 1c5a615b99d61938cc95eb5b209d9b3d4ebbcde0 Mon Sep 17 00:00:00 2001
From: Miriad
Date: Wed, 4 Mar 2026 13:20:12 +0000
Subject: [PATCH 09/13] =?UTF-8?q?feat:=20Phase=20D=20=E2=80=94=20Compariso?=
=?UTF-8?q?nGridScene=20+=20IsometricMockupScene=20components?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ComparisonGridScene: self-drawing SVG grid lines, staggered row fly-in,
timestamp-driven row highlighting, responsive stacked layout for portrait
- IsometricMockupScene: CSS 3D perspective device frames (browser/phone/terminal),
spring-animated tilt settling, typing animation for terminal mode
- Wire both components into SceneRouter (all 5 scene types now active)
- Zero new dependencies, no CSS transitions, backward compatible
---
remotion/components/ComparisonGridScene.tsx | 397 +++++++++++
remotion/components/IsometricMockupScene.tsx | 666 +++++++++++++++++++
remotion/components/SceneRouter.tsx | 10 +-
3 files changed, 1067 insertions(+), 6 deletions(-)
create mode 100644 remotion/components/ComparisonGridScene.tsx
create mode 100644 remotion/components/IsometricMockupScene.tsx
diff --git a/remotion/components/ComparisonGridScene.tsx b/remotion/components/ComparisonGridScene.tsx
new file mode 100644
index 00000000..bab0dd08
--- /dev/null
+++ b/remotion/components/ComparisonGridScene.tsx
@@ -0,0 +1,397 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+import type { ComparisonGridSceneProps } from "../types";
+import { ANIMATION, COLORS, COMPARISON_COLORS, FONT_SIZES } from "../constants";
+import { getActiveSegmentAtFrame } from "../../lib/utils/audio-timestamps";
+
+export const ComparisonGridScene: React.FC = ({
+ narration,
+ sceneIndex,
+ durationInFrames,
+ isVertical = false,
+ wordTimestamps,
+ leftLabel,
+ rightLabel,
+ rows,
+}) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape;
+
+ // Guard against empty data
+ if (!rows || rows.length === 0) {
+ return (
+
+ );
+ }
+
+ // --- Scene-level fade in/out ---
+ const sceneOpacity = interpolate(
+ frame,
+ [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // --- Narration text fade ---
+ const textOpacity = interpolate(
+ frame,
+ [
+ 0,
+ ANIMATION.fadeIn,
+ durationInFrames - ANIMATION.fadeOut,
+ durationInFrames,
+ ],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // --- Active segment (focus/dimming) ---
+ const activeSegment =
+ wordTimestamps && wordTimestamps.length > 0
+ ? getActiveSegmentAtFrame(wordTimestamps, rows.length, frame, fps)
+ : Math.min(
+ Math.floor((frame / durationInFrames) * rows.length),
+ rows.length - 1,
+ );
+
+ // Alternating gradient direction
+ const gradientAngle = (sceneIndex % 4) * 90;
+
+ // --- SVG Grid Line Drawing ---
+ // Grid dimensions
+ const gridWidth = isVertical ? 900 : 1400;
+ const headerHeight = 70;
+ const rowHeight = isVertical ? 120 : 70;
+ const gridHeight = headerHeight + rows.length * rowHeight;
+
+ const drawEnd = Math.round(durationInFrames * 0.2);
+
+ // Vertical divider line (center) — only in landscape
+ const verticalLineLength = gridHeight;
+ const verticalDrawProgress = interpolate(
+ frame,
+ [0, drawEnd],
+ [verticalLineLength, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // Horizontal line lengths
+ const horizontalLineLength = gridWidth;
+ const horizontalDrawProgress = interpolate(
+ frame,
+ [0, drawEnd],
+ [horizontalLineLength, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // --- Header entrance spring ---
+ const headerSpring = spring({
+ frame,
+ fps,
+ config: {
+ damping: ANIMATION.springDamping,
+ mass: ANIMATION.springMass,
+ stiffness: ANIMATION.springStiffness,
+ },
+ });
+
+ const headerTranslateY = interpolate(headerSpring, [0, 1], [30, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ return (
+
+ {/* Layer 1: Dark gradient background */}
+
+
+ {/* Layer 2: Comparison grid */}
+
+
+ {/* SVG Grid Lines Overlay */}
+
+
+ {/* Header Row */}
+
+ {/* Left label */}
+
+ {leftLabel}
+
+
+ {/* Right label */}
+
+ {rightLabel}
+
+
+
+ {/* Data Rows */}
+ {rows.map((row, index) => {
+ const staggerDelay = Math.round(durationInFrames * 0.05) + index * 4;
+
+ // Row entrance spring
+ const rowSpring =
+ frame >= staggerDelay
+ ? spring({
+ frame: frame - staggerDelay,
+ fps,
+ config: {
+ damping: ANIMATION.springDamping,
+ mass: ANIMATION.springMass,
+ stiffness: ANIMATION.springStiffness,
+ },
+ })
+ : 0;
+
+ const hasEntered = frame >= staggerDelay;
+ const isActive = hasEntered && index === activeSegment;
+
+ // Opacity: invisible before entrance, then active/inactive
+ const rowOpacity = !hasEntered
+ ? 0
+ : isActive
+ ? rowSpring
+ : rowSpring * 0.5;
+
+ // Transform values from spring
+ const translateY = interpolate(rowSpring, [0, 1], [30, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ const scale = isActive
+ ? interpolate(rowSpring, [0, 1], [0.95, 1.02], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ })
+ : interpolate(rowSpring, [0, 1], [0.95, 1.0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ const rowBg = isActive
+ ? COMPARISON_COLORS.activeRow
+ : "rgba(15, 15, 35, 0.6)";
+
+ return (
+
+ {/* Left cell */}
+
+ {row.left}
+
+
+ {/* Right cell */}
+
+ {row.right}
+
+
+ );
+ })}
+
+
+
+ {/* Layer 3: Narration text overlay (bottom) */}
+
+
+
+ {narration}
+
+
+
+
+ {/* Layer 4: CodingCat.dev watermark */}
+
+ codingcat.dev
+
+
+ );
+};
diff --git a/remotion/components/IsometricMockupScene.tsx b/remotion/components/IsometricMockupScene.tsx
new file mode 100644
index 00000000..7093ed5e
--- /dev/null
+++ b/remotion/components/IsometricMockupScene.tsx
@@ -0,0 +1,666 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+import type { IsometricMockupSceneProps } from "../types";
+import { ANIMATION, COLORS, CODE_COLORS, FONT_SIZES } from "../constants";
+
+/**
+ * IsometricMockupScene — CSS 3D device mockup that displays content
+ * in a realistic device frame (browser, phone, or terminal).
+ *
+ * Features:
+ * - Three device types: browser (Chrome-style), phone (notch), terminal (green-on-black)
+ * - CSS 3D perspective tilt with spring-animated entrance
+ * - Typing animation for terminal, fade-in for browser/phone
+ * - Narration overlay at bottom with blur backdrop
+ * - Watermark at bottom-right
+ */
+
+const CODE_FONT_FAMILY =
+ '"Fira Code", "JetBrains Mono", "Cascadia Code", monospace';
+
+/** Font sizes type — union of landscape and portrait */
+type FontSizes =
+ | (typeof FONT_SIZES)["landscape"]
+ | (typeof FONT_SIZES)["portrait"];
+
+// --- Browser Frame ---
+const BrowserFrame: React.FC<{
+ screenContent: string;
+ contentOpacity: number;
+ isVertical: boolean;
+ fonts: FontSizes;
+}> = ({ screenContent, contentOpacity, isVertical, fonts }) => {
+ // Determine if screenContent looks like a URL
+ const isUrl =
+ screenContent.startsWith("http://") ||
+ screenContent.startsWith("https://") ||
+ screenContent.startsWith("www.");
+ const urlBarText = isUrl ? screenContent : "https://example.com";
+ const viewportText = isUrl ? "" : screenContent;
+
+ return (
+
+ {/* Title bar */}
+
+ {/* Traffic light dots */}
+
+
+
+
+
+
+ {/* URL bar */}
+
+ {/* Lock icon */}
+
+ 🔒
+
+
+ {urlBarText}
+
+
+
+ {/* Spacer */}
+
+
+
+ {/* Content area */}
+
+ {viewportText ? (
+
+ {viewportText}
+
+ ) : (
+
+ {/* Placeholder content blocks for URL display */}
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+// --- Phone Frame ---
+const PhoneFrame: React.FC<{
+ screenContent: string;
+ contentOpacity: number;
+ isVertical: boolean;
+ fonts: FontSizes;
+}> = ({ screenContent, contentOpacity, isVertical, fonts }) => {
+ const phoneWidth = isVertical ? 320 : 300;
+ const phoneHeight = isVertical ? 580 : 540;
+
+ return (
+
+ {/* Notch */}
+
+
+
+
+ {/* Screen area */}
+
+
+ {screenContent}
+
+
+
+ {/* Home indicator bar */}
+
+
+
+
+ );
+};
+
+// --- Terminal Frame ---
+const TerminalFrame: React.FC<{
+ screenContent: string;
+ typedCharCount: number;
+ cursorVisible: boolean;
+ isVertical: boolean;
+ fonts: FontSizes;
+}> = ({ screenContent, typedCharCount, cursorVisible, isVertical, fonts }) => {
+ const displayText = screenContent.slice(0, typedCharCount);
+
+ return (
+
+ {/* Title bar */}
+
+ {/* Traffic light dots */}
+
+
+
+
+
+ {/* Terminal title */}
+
+ Terminal
+
+ {/* Spacer */}
+
+
+
+ {/* Terminal content area */}
+
+
+ {/* Prompt */}
+ $
+ {/* Typed text */}
+ {displayText}
+ {/* Blinking cursor */}
+ {cursorVisible && (
+
+ ▌
+
+ )}
+
+
+
+ );
+};
+
+// --- Main Component ---
+export const IsometricMockupScene: React.FC = ({
+ narration,
+ sceneIndex,
+ durationInFrames,
+ isVertical = false,
+ // wordTimestamps is accepted but not used for content sync in this component
+ // (reserved for future narration highlighting)
+ deviceType,
+ screenContent,
+}) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape;
+
+ // --- Scene-level fade in/out ---
+ const sceneOpacity = interpolate(
+ frame,
+ [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // --- Device entrance: spring fly-in from below ---
+ const entranceSpring = spring({
+ frame,
+ fps,
+ config: {
+ damping: ANIMATION.springDamping,
+ mass: ANIMATION.springMass,
+ stiffness: ANIMATION.springStiffness,
+ },
+ });
+
+ const translateY = interpolate(entranceSpring, [0, 1], [200, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // --- 3D tilt settling with spring ---
+ const tiltProgress = spring({
+ frame,
+ fps,
+ config: { damping: 15, mass: 0.8, stiffness: 80 },
+ });
+
+ // Different tilt targets per device type
+ let tiltXRange: [number, number];
+ let tiltYRange: [number, number];
+
+ if (deviceType === "browser") {
+ tiltXRange = [-15, -5];
+ tiltYRange = [20, 10];
+ } else if (deviceType === "phone") {
+ tiltXRange = [-10, -3];
+ tiltYRange = [-16, -8];
+ } else {
+ // terminal
+ tiltXRange = [-12, -4];
+ tiltYRange = [15, 8];
+ }
+
+ const tiltX = interpolate(tiltProgress, [0, 1], tiltXRange, {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const tiltY = interpolate(tiltProgress, [0, 1], tiltYRange, {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // --- Content appearance ---
+ const contentStartFrame = Math.round(durationInFrames * 0.15);
+ const contentEndFrame = Math.round(durationInFrames * 0.85);
+
+ // For browser/phone: fade-in opacity
+ const contentOpacity = interpolate(
+ frame,
+ [contentStartFrame, contentStartFrame + 20],
+ [0, 1],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // For terminal: typing animation (character count)
+ const typedCharCount = Math.floor(
+ interpolate(
+ frame,
+ [contentStartFrame, contentEndFrame],
+ [0, screenContent.length],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ ),
+ );
+
+ // Cursor blink (toggles every 15 frames)
+ const cursorVisible = Math.floor(frame / 15) % 2 === 0;
+
+ // --- Narration text animation ---
+ const narrationOpacity = interpolate(
+ frame,
+ [ANIMATION.fadeIn, ANIMATION.fadeIn + 10],
+ [0, 1],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
+ );
+
+ // --- Gradient background angle (alternating per scene) ---
+ const gradientAngle = (sceneIndex % 4) * 90;
+
+ return (
+
+ {/* Layer 1: Dark gradient background */}
+
+
+ {/* Layer 2: 3D Device mockup */}
+
+ {/* Perspective container */}
+
+ {/* 3D transform wrapper */}
+
+ {deviceType === "browser" && (
+
+ )}
+ {deviceType === "phone" && (
+
+ )}
+ {deviceType === "terminal" && (
+
+ )}
+
+
+
+
+ {/* Layer 3: Narration text overlay */}
+
+
+
+ {narration}
+
+
+
+
+ {/* Layer 4: CodingCat.dev watermark */}
+
+ codingcat.dev
+
+
+ );
+};
diff --git a/remotion/components/SceneRouter.tsx b/remotion/components/SceneRouter.tsx
index 7c14db6c..22b92a48 100644
--- a/remotion/components/SceneRouter.tsx
+++ b/remotion/components/SceneRouter.tsx
@@ -4,8 +4,8 @@ import { Scene } from "./Scene";
// Scene component imports (uncomment as components are built):
import { CodeMorphScene } from "./CodeMorphScene";
import { DynamicListScene } from "./DynamicListScene";
-// import { ComparisonGridScene } from "./ComparisonGridScene";
-// import { IsometricMockupScene } from "./IsometricMockupScene";
+import { ComparisonGridScene } from "./ComparisonGridScene";
+import { IsometricMockupScene } from "./IsometricMockupScene";
interface SceneRouterProps {
scene: SceneData;
@@ -47,15 +47,13 @@ export const SceneRouter: React.FC = ({
case "comparison":
if (scene.comparison) {
- // TODO: Replace with ComparisonGridScene when built
- // return ;
+ return ;
}
break;
case "mockup":
if (scene.mockup) {
- // TODO: Replace with IsometricMockupScene when built
- // return ;
+ return ;
}
break;
From d77a37305852a26bbe95157847607edb92884cd6 Mon Sep 17 00:00:00 2001
From: Miriad
Date: Wed, 4 Mar 2026 13:20:20 +0000
Subject: [PATCH 10/13] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?=
=?UTF-8?q?=20fail-closed=20auth,=20input=20validation,=20rendering=20stag?=
=?UTF-8?q?e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- All API routes now fail closed (503 if Supabase not configured)
- Settings PUT validates and whitelists fields (videosPerWeek, publishDays,
contentCategories, rateCardTiers) with type checking
- Uses createIfNotExists with deterministic ID for singleton safety
- Added missing 'rendering' pipeline stage (8 stages total)
- Consolidated GROQ queries (7 parallel calls → 1 query)
- Added AbortController cleanup to pipeline polling
- Use shared POLL_INTERVAL_MS constant
Co-authored-by: dashboard
---
app/api/dashboard/activity/route.ts | 19 ++--
app/api/dashboard/metrics/route.ts | 46 ++++-----
app/api/dashboard/pipeline/route.ts | 52 +++++-----
app/api/dashboard/settings/route.ts | 145 +++++++++++++++++++++-------
components/pipeline-status.tsx | 26 +++--
5 files changed, 192 insertions(+), 96 deletions(-)
diff --git a/app/api/dashboard/activity/route.ts b/app/api/dashboard/activity/route.ts
index 5dbacfc1..80437e99 100644
--- a/app/api/dashboard/activity/route.ts
+++ b/app/api/dashboard/activity/route.ts
@@ -10,14 +10,17 @@ export async function GET() {
process.env.NEXT_PUBLIC_SUPABASE_URL &&
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- if (hasSupabase) {
- const supabase = await createClient();
- const {
- data: { user },
- } = await supabase.auth.getUser();
- if (!user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ 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 {
diff --git a/app/api/dashboard/metrics/route.ts b/app/api/dashboard/metrics/route.ts
index 6f4d3519..36d74064 100644
--- a/app/api/dashboard/metrics/route.ts
+++ b/app/api/dashboard/metrics/route.ts
@@ -10,37 +10,31 @@ export async function GET() {
process.env.NEXT_PUBLIC_SUPABASE_URL &&
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- if (hasSupabase) {
- const supabase = await createClient();
- const {
- data: { user },
- } = await supabase.auth.getUser();
- if (!user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ 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 [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"])`,
- ),
- ]);
+ 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: videosPublished ?? 0,
- flaggedForReview: (flaggedVideos ?? 0) + (newIdeas ?? 0),
- sponsorPipeline: sponsorPipeline ?? 0,
+ videosPublished: counts?.videosPublished ?? 0,
+ flaggedForReview: (counts?.flaggedVideos ?? 0) + (counts?.newIdeas ?? 0),
+ sponsorPipeline: counts?.sponsorPipeline ?? 0,
revenue: null,
};
diff --git a/app/api/dashboard/pipeline/route.ts b/app/api/dashboard/pipeline/route.ts
index 67b53060..8836d991 100644
--- a/app/api/dashboard/pipeline/route.ts
+++ b/app/api/dashboard/pipeline/route.ts
@@ -5,33 +5,41 @@ import { dashboardQuery } from "@/lib/sanity/dashboard";
export const dynamic = "force-dynamic";
export async function GET() {
- const hasSupabase = process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- if (hasSupabase) {
- const supabase = await createClient();
- const { data: { user } } = await supabase.auth.getUser();
- if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ const hasSupabase =
+ process.env.NEXT_PUBLIC_SUPABASE_URL &&
+ process.env.NEXT_PUBLIC_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 [draft, scriptReady, audioGen, videoGen, flagged, uploading, published] = await Promise.all([
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "draft"])`),
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "script_ready"])`),
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "audio_gen"])`),
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "video_gen"])`),
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "flagged"])`),
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "uploading"])`),
- dashboardQuery(`count(*[_type == "automatedVideo" && status == "published"])`),
- ]);
+ // 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({
- draft: draft ?? 0,
- scriptReady: scriptReady ?? 0,
- audioGen: audioGen ?? 0,
- videoGen: videoGen ?? 0,
- flagged: flagged ?? 0,
- uploading: uploading ?? 0,
- published: published ?? 0,
- total: (draft ?? 0) + (scriptReady ?? 0) + (audioGen ?? 0) + (videoGen ?? 0) + (flagged ?? 0) + (uploading ?? 0) + (published ?? 0),
+ ...counts,
+ total,
});
} catch (error) {
console.error("Failed to fetch pipeline status:", error);
diff --git a/app/api/dashboard/settings/route.ts b/app/api/dashboard/settings/route.ts
index 89ff3e9b..fb8259a8 100644
--- a/app/api/dashboard/settings/route.ts
+++ b/app/api/dashboard/settings/route.ts
@@ -4,15 +4,105 @@ import { dashboardQuery, dashboardClient } from "@/lib/sanity/dashboard";
export const dynamic = "force-dynamic";
-export async function GET() {
- // Auth check (skip if Supabase not configured)
- const hasSupabase = process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- if (hasSupabase) {
- const supabase = await createClient();
- const { data: { user } } = await supabase.auth.getUser();
- if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+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.NEXT_PUBLIC_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] {
@@ -22,16 +112,7 @@ export async function GET() {
rateCardTiers[] { name, description, price }
}`
);
- return NextResponse.json(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 },
- ],
- });
+ 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 });
@@ -39,12 +120,8 @@ export async function GET() {
}
export async function PUT(request: Request) {
- const hasSupabase = process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
- if (hasSupabase) {
- const supabase = await createClient();
- const { data: { user } } = await supabase.auth.getUser();
- if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ const auth = await requireAuth();
+ if (auth.error) return auth.error;
if (!dashboardClient) {
return NextResponse.json({ error: "Sanity client not available" }, { status: 503 });
@@ -52,19 +129,21 @@ export async function PUT(request: Request) {
try {
const body = await request.json();
+ const validation = validateSettings(body);
- // Find or create the settings document
- const existing = await dashboardQuery(`*[_type == "dashboardSettings"][0]{ _id }`);
-
- if (existing?._id) {
- await dashboardClient.patch(existing._id).set(body).commit();
- } else {
- await dashboardClient.create({
- _type: "dashboardSettings",
- ...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);
diff --git a/components/pipeline-status.tsx b/components/pipeline-status.tsx
index afec60e2..fa20588b 100644
--- a/components/pipeline-status.tsx
+++ b/components/pipeline-status.tsx
@@ -1,14 +1,14 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
-
-const POLL_INTERVAL_MS = 30_000;
+import { POLL_INTERVAL_MS } from "@/lib/types/dashboard";
interface PipelineData {
draft: number;
scriptReady: number;
audioGen: number;
+ rendering: number;
videoGen: number;
flagged: number;
uploading: number;
@@ -26,6 +26,7 @@ const STAGES: {
{ 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" },
@@ -35,14 +36,22 @@ const STAGES: {
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");
+ const res = await fetch("/api/dashboard/pipeline", {
+ signal: controller.signal,
+ });
if (res.ok) {
setData(await res.json());
}
- } catch {
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") return;
// Silently fail — will retry on next poll
} finally {
setLoading(false);
@@ -52,7 +61,10 @@ export function PipelineStatus() {
useEffect(() => {
fetchPipeline();
const interval = setInterval(fetchPipeline, POLL_INTERVAL_MS);
- return () => clearInterval(interval);
+ return () => {
+ clearInterval(interval);
+ abortRef.current?.abort();
+ };
}, [fetchPipeline]);
if (loading) {
@@ -111,7 +123,7 @@ export function PipelineStatus() {
{i < STAGES.length - 1 && (
- →
+ \u2192
)}
))}
From b18c9e93bea65ed885fd24542d43f1d76e795195 Mon Sep 17 00:00:00 2001
From: Miriad
Date: Wed, 4 Mar 2026 13:27:00 +0000
Subject: [PATCH 11/13] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?=
=?UTF-8?q?=20dynamic=20year,=20typed=20YouTube=20response,=20nullable=20p?=
=?UTF-8?q?arseJsonOutput?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace hardcoded "2025" in YouTube queries with dynamic year
- Add YouTubeSearchItem/YouTubeSearchResponse/YouTubeVideoStats interfaces
- Change parseJsonOutput to return T | null instead of {} as T
- Update all callers to use optional chaining for null safety
Co-authored-by: research
---
lib/services/research.ts | 14 ++++++-------
lib/services/trend-discovery.ts | 36 ++++++++++++++++++++++++---------
2 files changed, 33 insertions(+), 17 deletions(-)
diff --git a/lib/services/research.ts b/lib/services/research.ts
index cf90fcf5..6c8d6c42 100644
--- a/lib/services/research.ts
+++ b/lib/services/research.ts
@@ -167,11 +167,11 @@ async function runNotebookLM(
}
}
-/** Parse JSON from CLI stdout, with a fallback for non-JSON output. */
-function parseJsonOutput(stdout: string): T {
+/** Parse JSON from CLI stdout, with a fallback for non-JSON output. Returns null for empty input. */
+function parseJsonOutput(stdout: string): T | null {
const trimmed = stdout.trim();
if (!trimmed) {
- return {} as T;
+ return null;
}
try {
return JSON.parse(trimmed) as T;
@@ -530,7 +530,7 @@ export async function conductResearch(
const createResult = parseJsonOutput<{ id?: string; notebook_id?: string }>(
createOutput
);
- const notebookId = createResult.id || createResult.notebook_id || "";
+ const notebookId = createResult?.id || createResult?.notebook_id || "";
if (!notebookId) {
throw new Error(
@@ -625,7 +625,7 @@ export async function conductResearch(
// Parse artifact results
const mindMap = mindMapResult
- ? (parseJsonOutput<{ content?: string }>(mindMapResult).content ??
+ ? (parseJsonOutput<{ content?: string }>(mindMapResult)?.content ??
mindMapResult.trim())
: undefined;
@@ -635,7 +635,7 @@ export async function conductResearch(
const briefing = briefingParsed?.content ?? briefingResult?.trim() ?? "";
const dataTable = dataTableResult
- ? (parseJsonOutput<{ content?: string }>(dataTableResult).content ??
+ ? (parseJsonOutput<{ content?: string }>(dataTableResult)?.content ??
dataTableResult.trim())
: undefined;
@@ -665,7 +665,7 @@ export async function conductResearch(
text?: string;
}>(result);
answers[key] =
- parsed.answer || parsed.response || parsed.text || result.trim();
+ parsed?.answer || parsed?.response || parsed?.text || result.trim();
}
}
diff --git a/lib/services/trend-discovery.ts b/lib/services/trend-discovery.ts
index ca6fcb2a..b0f9da4e 100644
--- a/lib/services/trend-discovery.ts
+++ b/lib/services/trend-discovery.ts
@@ -427,6 +427,28 @@ function parseFeedItems(xml: string): FeedItem[] {
// Source 1: Hacker News
// ---------------------------------------------------------------------------
+/** YouTube Data API v3 search response shape. */
+interface YouTubeSearchItem {
+ id: { videoId: string };
+ snippet: {
+ title: string;
+ publishedAt: string;
+ channelTitle: string;
+ };
+}
+
+interface YouTubeSearchResponse {
+ items?: YouTubeSearchItem[];
+}
+
+/** YouTube Data API v3 video statistics response shape. */
+interface YouTubeVideoStats {
+ items?: Array<{
+ id: string;
+ statistics: { viewCount?: string };
+ }>;
+}
+
interface HNItem {
id: number;
title?: string;
@@ -654,7 +676,8 @@ async function fetchYouTube(
Date.now() - lookbackDays * 86400 * 1000,
).toISOString();
- const queries = ["web development 2025", "nextjs react tutorial", "AI coding tools"];
+ const currentYear = new Date().getFullYear();
+ const queries = [`web development ${currentYear}`, "nextjs react tutorial", "AI coding tools"];
const results = await Promise.allSettled(
queries.map(async (q) => {
const params = new URLSearchParams({
@@ -673,15 +696,8 @@ async function fetchYouTube(
console.warn(`${LOG_PREFIX} YouTube search failed: ${res.status}`);
return [];
}
- const data = await res.json();
- return (data.items ?? []) as Array<{
- id: { videoId: string };
- snippet: {
- title: string;
- publishedAt: string;
- channelTitle: string;
- };
- }>;
+ const data: YouTubeSearchResponse = await res.json();
+ return (data.items ?? []);
}),
);
From 69a9e8bff2e7f77591e5f667e3a9dcbc836b6b9b Mon Sep 17 00:00:00 2001
From: Miriad
Date: Wed, 4 Mar 2026 13:31:13 +0000
Subject: [PATCH 12/13] feat: wire trend discovery + research into ingest cron
route
---
app/api/cron/ingest/route.ts | 203 ++++++++++++++++++-----------------
1 file changed, 106 insertions(+), 97 deletions(-)
diff --git a/app/api/cron/ingest/route.ts b/app/api/cron/ingest/route.ts
index adb38195..da4e601b 100644
--- a/app/api/cron/ingest/route.ts
+++ b/app/api/cron/ingest/route.ts
@@ -4,16 +4,13 @@ import type { NextRequest } from "next/server";
import { generateWithGemini, stripCodeFences } from "@/lib/gemini";
import { writeClient } from "@/lib/sanity-write-client";
+import { discoverTrends, type TrendResult } from "@/lib/services/trend-discovery";
+import { conductResearch, type ResearchPayload } from "@/lib/services/research";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
-interface RSSItem {
- title: string;
- url: string;
-}
-
interface ScriptScene {
sceneNumber: number;
sceneType: "narration" | "code" | "list" | "comparison" | "mockup";
@@ -62,122 +59,107 @@ interface CriticResult {
}
// ---------------------------------------------------------------------------
-// RSS Feed Helpers
+// Fallback topics (used when discoverTrends returns empty)
// ---------------------------------------------------------------------------
-const RSS_FEEDS = [
- "https://hnrss.org/newest?q=javascript+OR+react+OR+nextjs+OR+typescript&points=50",
- "https://dev.to/feed/tag/webdev",
-];
-
-const FALLBACK_TOPICS: RSSItem[] = [
+const FALLBACK_TRENDS: TrendResult[] = [
{
- title: "React Server Components: The Future of Web Development",
- url: "https://react.dev/blog",
+ 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",
},
{
- title: "TypeScript 5.x: New Features Every Developer Should Know",
- url: "https://devblogs.microsoft.com/typescript/",
+ 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",
},
{
- title: "Next.js App Router Best Practices for 2025",
- url: "https://nextjs.org/blog",
+ 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",
},
{
- title: "The State of CSS in 2025: Container Queries, Layers, and More",
- url: "https://web.dev/blog",
+ 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",
},
{
- title: "WebAssembly is Changing How We Build Web Apps",
- url: "https://webassembly.org/",
+ 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",
},
];
-function extractRSSItems(xml: string): RSSItem[] {
- const items: RSSItem[] = [];
- const itemRegex = /- ([\s\S]*?)<\/item>/gi;
- let itemMatch: RegExpExecArray | null;
+// ---------------------------------------------------------------------------
+// Gemini Script Generation
+// ---------------------------------------------------------------------------
- while ((itemMatch = itemRegex.exec(xml)) !== null) {
- const block = itemMatch[1];
+const SYSTEM_INSTRUCTION =
+ "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).";
- const titleMatch = block.match(/
<\/title>/);
- const titleAlt = block.match(/(.*?)<\/title>/);
- const title = titleMatch?.[1] ?? titleAlt?.[1] ?? "";
+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");
- const linkMatch = block.match(/(.*?)<\/link>/);
- const url = linkMatch?.[1] ?? "";
+ // 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 (title && url) {
- items.push({ title: title.trim(), url: url.trim() });
+ if (research.talkingPoints.length > 0) {
+ researchContext += `### Key Talking Points\n${research.talkingPoints.map((tp, i) => `${i + 1}. ${tp}`).join("\n")}\n\n`;
}
- }
- return items;
-}
-
-async function fetchTrendingTopics(): Promise {
- const allItems: RSSItem[] = [];
+ 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`;
+ }
+ }
- const results = await Promise.allSettled(
- RSS_FEEDS.map(async (feedUrl) => {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 10_000);
- try {
- const res = await fetch(feedUrl, { signal: controller.signal });
- if (!res.ok) {
- console.warn(
- `[CRON/ingest] RSS fetch failed for ${feedUrl}: ${res.status}`,
- );
- return [];
+ 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`;
}
- const xml = await res.text();
- return extractRSSItems(xml);
- } finally {
- clearTimeout(timeout);
+ researchContext += "\n";
}
- }),
- );
-
- for (const result of results) {
- if (result.status === "fulfilled") {
- allItems.push(...result.value);
- } else {
- console.warn("[CRON/ingest] RSS feed error:", result.reason);
}
- }
- const seen = new Set();
- const unique = allItems.filter((item) => {
- const key = item.title.toLowerCase();
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
+ if (research.sceneHints.length > 0) {
+ researchContext += `### Scene Type Suggestions\n`;
+ for (const hint of research.sceneHints) {
+ researchContext += `- ${hint.suggestedSceneType}: ${hint.reason}\n`;
+ }
+ }
- if (unique.length === 0) {
- console.warn("[CRON/ingest] No RSS items fetched, using fallback topics");
- return FALLBACK_TOPICS;
+ if (research.infographicPath) {
+ 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`;
+ }
}
- return unique.slice(0, 10);
-}
-
-// ---------------------------------------------------------------------------
-// Gemini Script Generation
-// ---------------------------------------------------------------------------
-
-const SYSTEM_INSTRUCTION =
- "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).";
-
-function buildPrompt(topics: RSSItem[]): string {
- const topicList = topics
- .map((t, i) => `${i + 1}. "${t.title}" — ${t.url}`)
- .join("\n");
-
return `Here are today's trending web development topics:
-${topicList}
+${topicList}${researchContext}
Pick the MOST interesting and timely topic for a short explainer video (60-90 seconds). Then generate a complete video script as JSON.
@@ -334,6 +316,8 @@ Respond with ONLY the JSON object.`,
async function createSanityDocuments(
script: GeneratedScript,
criticResult: CriticResult,
+ trends: TrendResult[],
+ research?: ResearchPayload,
) {
const isFlagged = criticResult.score < 50;
@@ -369,6 +353,9 @@ async function createSanityDocuments(
...(isFlagged && {
flaggedReason: `Quality score ${criticResult.score}/100. Issues: ${criticResult.issues.join("; ") || "Low quality score"}`,
}),
+ trendScore: trends[0]?.score,
+ trendSources: trends[0]?.signals.map(s => s.source).join(", "),
+ researchNotebookId: research?.notebookId,
});
console.log(`[CRON/ingest] Created automatedVideo: ${automatedVideo._id}`);
@@ -394,12 +381,32 @@ export async function GET(request: NextRequest) {
}
try {
- console.log("[CRON/ingest] Fetching trending topics...");
- const topics = await fetchTrendingTopics();
- console.log(`[CRON/ingest] Found ${topics.length} topics`);
+ // Step 1: Discover trending topics (replaces fetchTrendingTopics)
+ console.log("[CRON/ingest] Discovering trending topics...");
+ let trends = await discoverTrends({ lookbackDays: 7, maxTopics: 10 });
+ console.log(`[CRON/ingest] Found ${trends.length} trending topics`);
+
+ // Fall back to hardcoded topics if discovery returns empty
+ if (trends.length === 0) {
+ console.warn("[CRON/ingest] No trends discovered, using fallback topics");
+ trends = FALLBACK_TRENDS;
+ }
+
+ // Step 2: Optional deep research on top topic
+ let research: ResearchPayload | undefined;
+ if (process.env.ENABLE_NOTEBOOKLM_RESEARCH === "true") {
+ console.log(`[CRON/ingest] Conducting research on: "${trends[0].topic}"...`);
+ try {
+ research = await conductResearch(trends[0].topic);
+ console.log(`[CRON/ingest] Research complete: ${research.sources.length} sources, ${research.sceneHints.length} scene hints`);
+ } catch (err) {
+ console.warn("[CRON/ingest] Research failed, continuing without:", err);
+ }
+ }
+ // Step 3: Generate script with Gemini (enriched with research)
console.log("[CRON/ingest] Generating script with Gemini...");
- const prompt = buildPrompt(topics);
+ const prompt = buildPrompt(trends, research);
const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
let script: GeneratedScript;
@@ -430,7 +437,7 @@ export async function GET(request: NextRequest) {
);
console.log("[CRON/ingest] Creating Sanity documents...");
- const result = await createSanityDocuments(script, criticResult);
+ const result = await createSanityDocuments(script, criticResult, trends, research);
console.log("[CRON/ingest] Done!", result);
@@ -439,7 +446,9 @@ export async function GET(request: NextRequest) {
...result,
title: script.title,
criticScore: criticResult.score,
- topicCount: topics.length,
+ trendCount: trends.length,
+ trendScore: trends[0]?.score,
+ researchEnabled: !!research,
});
} catch (err) {
console.error("[CRON/ingest] Unexpected error:", err);
From 433ceff0c4d122adbf5f6811f84077a06270a069 Mon Sep 17 00:00:00 2001
From: Miriad
Date: Wed, 4 Mar 2026 13:36:22 +0000
Subject: [PATCH 13/13] fix: wrap discoverTrends() in try/catch for graceful
fallback
If trend discovery throws (network error, API down, etc.), falls back
to FALLBACK_TRENDS instead of returning a 500. Matches the same
resilient pattern used for conductResearch().
Co-authored-by: research
---
app/api/cron/ingest/route.ts | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/app/api/cron/ingest/route.ts b/app/api/cron/ingest/route.ts
index da4e601b..69b0105b 100644
--- a/app/api/cron/ingest/route.ts
+++ b/app/api/cron/ingest/route.ts
@@ -383,10 +383,16 @@ export async function GET(request: NextRequest) {
try {
// Step 1: Discover trending topics (replaces fetchTrendingTopics)
console.log("[CRON/ingest] Discovering trending topics...");
- let trends = await discoverTrends({ lookbackDays: 7, maxTopics: 10 });
- console.log(`[CRON/ingest] Found ${trends.length} 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
+ // 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;
+ const descMatch = block.match( + /
]*class="[^"]*col-9[^"]*"[^>]*>([\s\S]*?)<\/p>/,
+ );
+ const description = (descMatch?.[1] ?? "")
+ .replace(/<[^>]+>/g, "")
+ .trim();
+
+ // Stars today from "N stars today" text
+ const starsMatch = block.match(
+ /(\d[\d,]*)\s+stars?\s+today/i,
+ );
+ const starsToday = starsMatch
+ ? parseInt(starsMatch[1].replace(/,/g, ""), 10)
+ : 0;
+
+ repos.push({
+ name,
+ description,
+ url,
+ starsToday,
+ language,
+ });
+ }
+
+ return repos;
+}
+
+/**
+ * Fetch trending repos from GitHub for multiple languages.
+ */
+async function fetchGitHubTrending(): Promise