From f0a7ba4ad366049eec7deec2ca147451769026ee Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Tue, 16 Jun 2026 14:50:06 +0800 Subject: [PATCH 1/4] chore(auto-carousel): delete API route, form component, and page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes all files that constitute the auto-carousel feature: - app/api/auto-carousel/route.ts — Claude API + CaptionExtractor backend - app/components/AutoCarouselForm.tsx — the pro-gated form UI - app/auto-carousel/page.tsx — the route page (Issue #76 scope, pulled forward because AutoCarouselForm was its only meaningful dependency and now gone) Deleting the page here rather than leaving a broken import unresolvable by tsc. AC: POST /api/auto-carousel now returns 404; no AutoCarouselForm import anywhere --- app/api/auto-carousel/route.ts | 327 ----------- app/auto-carousel/page.tsx | 70 --- app/components/AutoCarouselForm.tsx | 810 ---------------------------- 3 files changed, 1207 deletions(-) delete mode 100644 app/api/auto-carousel/route.ts delete mode 100644 app/auto-carousel/page.tsx delete mode 100644 app/components/AutoCarouselForm.tsx diff --git a/app/api/auto-carousel/route.ts b/app/api/auto-carousel/route.ts deleted file mode 100644 index 250a6c3..0000000 --- a/app/api/auto-carousel/route.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import CaptionExtractor from '@/scripts/carousel/CaptionExtractor'; - -const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ''; -const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514'; - -interface Sentence { - timestamp: number; - text: string; -} - -interface LLMCarousel { - carouselTitle: string; - slides: { - topTimestamp: number; - bottomTimestamp: number; - topText: string; - bottomText: string; - }[]; -} - -interface OutputSlide { - topTimestamp: number; - bottomTimestamp: number; - topText: string; - bottomText: string; -} - -interface OutputCarousel { - carouselTitle: string; - slides: OutputSlide[]; -} - -// ── Claude API call ───────────────────────────────────────────────────── - -async function callClaude(prompt: string, maxTokens = 8192): Promise { - const response = 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_MODEL, - max_tokens: maxTokens, - messages: [{ role: 'user', content: prompt }], - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Claude API request failed (${response.status}): ${text}`); - } - - const data = await response.json(); - const content = data.content?.[0]; - return content?.text || ''; -} - -function hasApiKey(): boolean { - return ANTHROPIC_API_KEY.length > 0; -} - -// ── Single LLM prompt: select segments + clean for carousel engagement ── - -function buildCarouselPrompt( - sentences: Sentence[], - numCarousels: number, - slidesPerCarousel: number, - videoDuration: number -): string { - const transcriptText = sentences - .map((s) => `[${s.timestamp}s] ${s.text}`) - .join('\n'); - - const firstTs = sentences[0]?.timestamp ?? 0; - const lastTs = sentences[sentences.length - 1]?.timestamp ?? 0; - const totalDuration = lastTs - firstTs; - const idealSpacing = Math.floor(totalDuration / (numCarousels + 1)); - const minSpacing = Math.max(60, Math.floor(totalDuration / (numCarousels * 2))); - - return `You are a social media content strategist creating Instagram carousel posts from a video transcript. - -TASK: Analyze this transcript and produce ${numCarousels} carousels, each with ${slidesPerCarousel} slides. Each slide has a top text and bottom text overlaid on a video screenshot. - -IMPORTANT: The video is ${Math.floor(videoDuration)} seconds long. ALL timestamps must be between 0 and ${Math.floor(videoDuration)} seconds. Do NOT use timestamps beyond this range. - -STEP 1 — SELECT SEGMENTS: -1. Identify ${numCarousels} that would make engaging carousel content -2. For each takeaway, select a ~30-60 second sequential segment from the transcript -3. Break each segment into ${slidesPerCarousel} slides with sequential timestamps - -STEP 2 — CLEAN & FORMAT EACH SEGMENT FOR SLIDES: -- For each slide, pick two consecutive moments from the segment. Each moment becomes one line of text (topText and bottomText). -- Clean up the transcript text for each line: - • Fix grammar errors and typos (e.g. "jarens" → "jargons", "I I don't" → "I don't"). - • Remove filler words (uh, um, hmm) ONLY if it doesn't change meaning. - • Break long sentences into concise clauses. Each line should be 5-20 words — punchy and readable on a phone screen. - • Remove pure filler ("Yeah.", "Right.", "Uh huh.") — skip those moments entirely. - • Do NOT invent new content. Only clean up what's in the transcript. -- topTimestamp and bottomTimestamp must be actual timestamps from the transcript (the [Xs] values). -- Slides within a carousel must progress forward in time. -- Slide 1 of each carousel should be the hook — the most attention-grabbing line. - -TRANSCRIPT: -${transcriptText} - -Respond with ONLY valid JSON, no other text: -{ - "carousels": [ - { - "carouselTitle": "Short Topic Title", - "slides": [ - { - "topTimestamp": 45, - "bottomTimestamp": 48, - "topText": "cleaned concise hook line", - "bottomText": "cleaned concise follow-up line" - }, - { - "topTimestamp": 52, - "bottomTimestamp": 55, - "topText": "next concise point", - "bottomText": "supporting detail" - } - ] - } - ] -} - -Rules for slides: -- Timestamps must be SEQUENTIAL within each carousel (not jumping around) -- Each slide covers ~3-8 seconds of dialogue -- topText and bottomText should be punchy, quotable snippets -- Clean up filler words (um, uh, like) but keep the authentic voice -- Each carousel should tell a complete mini-story or make one clear point - -Rules for slides: -- Timestamps must be SEQUENTIAL within each carousel (not jumping around) -- Each slide covers ~3-8 seconds of dialogue -- topText and bottomText should be punchy, quotable snippets -- Clean up filler words (um, uh, like) but keep the authentic voice -- Each carousel should tell a complete mini-story or make one clear point`; -} - -// ── Parse LLM response ────────────────────────────────────────────────── - -function parseLLMResponse(response: string, maxTimestamp: number): OutputCarousel[] { - const jsonMatch = response.match(/\{[\s\S]*\}/); - if (!jsonMatch) throw new Error('No JSON found in response'); - - const parsed = JSON.parse(jsonMatch[0]); - if (!parsed.carousels || !Array.isArray(parsed.carousels)) { - throw new Error('Invalid response format — missing carousels array'); - } - - const carousels: OutputCarousel[] = []; - - for (const c of parsed.carousels as LLMCarousel[]) { - if (!c.slides || !Array.isArray(c.slides) || c.slides.length < 2) continue; - - const validSlides = c.slides.filter( - (s) => - typeof s.topTimestamp === 'number' && - typeof s.bottomTimestamp === 'number' && - typeof s.topText === 'string' && - typeof s.bottomText === 'string' && - s.topText.trim().length > 0 && - s.bottomText.trim().length > 0 && - s.topTimestamp >= 0 && - s.topTimestamp <= maxTimestamp && - s.bottomTimestamp >= 0 && - s.bottomTimestamp <= maxTimestamp - ); - - if (validSlides.length >= 2) { - carousels.push({ - carouselTitle: c.carouselTitle || 'Untitled', - slides: validSlides, - }); - console.log(` Carousel "${c.carouselTitle}": ${validSlides.length} valid slides (filtered ${c.slides.length - validSlides.length} invalid)`); - } - } - - return carousels; -} - -// ── API handler ───────────────────────────────────────────────────────── -// -// PRODUCTION (ANTHROPIC_API_KEY is set): -// Single POST { videoId, numCarousels, slidesPerCarousel } -// → transcribes, calls Claude with single prompt, returns carousels -// -// LOCAL TESTING (no API key): -// Step 1: POST { videoId, numCarousels, slidesPerCarousel } -// → returns { mode: "manual", prompt, ... } -// -// Step 2: POST { step: "build", llmResponse } -// → parses response, returns { success, carousels } - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - // ── Manual mode: user submits Claude response ───────────────────── - if (body.step === 'build') { - const { llmResponse, maxTimestamp = 3600 } = body; - - if (!llmResponse) { - return NextResponse.json({ error: 'Missing llmResponse' }, { status: 400 }); - } - - let carousels: OutputCarousel[]; - try { - carousels = parseLLMResponse(llmResponse, maxTimestamp); - } catch (e) { - return NextResponse.json( - { error: `Failed to parse response: ${e instanceof Error ? e.message : 'unknown error'}` }, - { status: 400 } - ); - } - - if (carousels.length === 0) { - return NextResponse.json({ error: 'No valid carousels found in response.' }, { status: 400 }); - } - - console.log(` Built ${carousels.length} carousels from manual response`); - return NextResponse.json({ success: true, carousels }); - } - - // ── Step 1: Transcribe + generate prompt ────────────────────────── - const { - videoId, - numCarousels = 3, - slidesPerCarousel = 5, - } = body; - - if (!videoId) { - return NextResponse.json({ error: 'Missing videoId' }, { status: 400 }); - } - - console.log(`\n🤖 Auto-carousel: Transcribing video ${videoId}`); - - const extractor = new CaptionExtractor(); - await extractor.init(); - let transcription: { sentences: Sentence[]; fullText: string }; - try { - transcription = await extractor.transcribeVideo(videoId, { removeFillers: true }); - } finally { - await extractor.close(); - } - - if (!transcription.sentences || transcription.sentences.length === 0) { - return NextResponse.json( - { error: 'No captions found for this video' }, - { status: 400 } - ); - } - - const rawSentences = transcription.sentences; - console.log(` Transcribed ${rawSentences.length} raw sentences`); - - // Calculate video duration from transcript (use conservative estimate to avoid YouTube errors) - const maxTranscriptTime = rawSentences.length > 0 - ? Math.max(...rawSentences.map(s => s.timestamp)) - : 3600; - // Use transcript end time minus 15s safety margin (YouTube may not buffer beyond transcript, and videos may be shorter) - const videoDuration = Math.max(60, maxTranscriptTime - 15); - console.log(` Transcript ends at ${Math.floor(maxTranscriptTime)}s, using max timestamp ${Math.floor(videoDuration)}s for safety`); - - const prompt = buildCarouselPrompt(rawSentences, numCarousels, slidesPerCarousel, videoDuration); - - if (hasApiKey()) { - // ── Production: call Claude directly ─────────────────────────── - console.log(` Calling Claude (${CLAUDE_MODEL})...`); - - const llmResponse = await callClaude(prompt); - console.log(` Response: ${llmResponse.length} chars`); - - let carousels: OutputCarousel[]; - try { - carousels = parseLLMResponse(llmResponse, videoDuration); - } catch (e) { - console.error('Failed to parse Claude response:', llmResponse.substring(0, 500)); - return NextResponse.json( - { error: 'Failed to parse Claude response. Try again.' }, - { status: 500 } - ); - } - - if (carousels.length === 0) { - return NextResponse.json({ error: 'Claude did not return valid carousels. Try again.' }, { status: 500 }); - } - - console.log(` Built ${carousels.length} carousels`); - - return NextResponse.json({ - success: true, - carousels, - transcription: { - sentenceCount: rawSentences.length, - fullText: transcription.fullText, - }, - }); - } - - // ── Manual mode: return prompt for user ────────────────────────── - console.log(' No ANTHROPIC_API_KEY — returning prompt for manual mode'); - - return NextResponse.json({ - mode: 'manual', - prompt, - maxTimestamp: videoDuration, - transcription: { - sentenceCount: rawSentences.length, - fullText: transcription.fullText, - }, - }); - } catch (error) { - console.error('Auto-carousel error:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to generate auto-carousel' }, - { status: 500 } - ); - } -} diff --git a/app/auto-carousel/page.tsx b/app/auto-carousel/page.tsx deleted file mode 100644 index e0b1408..0000000 --- a/app/auto-carousel/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { Container, Stack, Paper, Title, Text, Button, Group, ThemeIcon, Badge, Loader, Center } from '@mantine/core'; -import { IconLock, IconWand } from '@tabler/icons-react'; -import { Header } from '../components/Header'; -import { AutoCarouselForm } from '../components/AutoCarouselForm'; -import { useAuth } from '../context/AuthContext'; - -export default function AutoCarouselPage() { - const { user, isLoading } = useAuth(); - const router = useRouter(); - - if (isLoading) { - return ( -
-
-
- -
-
- ); - } - - if (!user || !user.isSubscribed) { - return ( -
-
- - - - - - - Pro Feature - Auto Bulk Carousel Generator - - This premium feature uses a local LLM to analyze your video transcript and - automatically generate multiple carousel posts with attention-grabbing hooks - and call-to-action slides. - - - - - Demo: demo@deckcreate.com / demo123 - - - - - -
- ); - } - - return ( -
-
- - - -
- ); -} diff --git a/app/components/AutoCarouselForm.tsx b/app/components/AutoCarouselForm.tsx deleted file mode 100644 index ef792c8..0000000 --- a/app/components/AutoCarouselForm.tsx +++ /dev/null @@ -1,810 +0,0 @@ -'use client'; - -import { useState, useRef } from 'react'; -import { - TextInput, - Button, - Stack, - Group, - Paper, - Title, - Text, - NumberInput, - Alert, - Image, - Accordion, - Textarea, - ColorInput, - Checkbox, - Badge, - ActionIcon, - Tooltip, - Select, - Box, - Menu, - Modal, - CopyButton, - Code, -} from '@mantine/core'; -import { Carousel } from '@mantine/carousel'; -import { notifications } from '@mantine/notifications'; -import { - IconWand, - IconRocket, - IconDownload, - IconBrandInstagram, - IconBrandYoutube, - IconBrandTiktok, - IconBrandLinkedin, - IconBrandSpotify, - IconBrandApple, - IconFileZip, - IconPhoto, - IconChevronDown, - IconClipboard, - IconCheck, - IconArrowRight, -} from '@tabler/icons-react'; -import JSZip from 'jszip'; -import { saveAs } from 'file-saver'; -import { extractVideoId, getVideoTitle } from '../utils/youtube'; - -interface SlideData { - topTimestamp: number; - bottomTimestamp: number; - topText: string; - bottomText: string; -} - -interface CarouselSuggestion { - carouselTitle: string; - slides: SlideData[]; -} - -interface GeneratedSlide { - base64: string; - filename: string; -} - -interface GeneratedCarousel { - title: string; - slides: GeneratedSlide[]; -} - -const CTA_PRESETS = [ - { value: 'follow', label: 'Follow us for more' }, - { value: 'stream', label: 'Stream our latest episode on' }, - { value: 'custom', label: 'Custom text' }, -]; - -const PLATFORM_OPTIONS = [ - { id: 'instagram', label: 'Instagram', icon: IconBrandInstagram }, - { id: 'youtube', label: 'YouTube', icon: IconBrandYoutube }, - { id: 'tiktok', label: 'TikTok', icon: IconBrandTiktok }, - { id: 'linkedin', label: 'LinkedIn', icon: IconBrandLinkedin }, - { id: 'spotify', label: 'Spotify', icon: IconBrandSpotify }, - { id: 'apple', label: 'Apple Podcasts', icon: IconBrandApple }, -]; - -const BG_COLOR_PRESETS = [ - { color: '#1a1a2e', label: 'Dark Navy' }, - { color: '#0f0f0f', label: 'Black' }, - { color: '#fc8b94', label: 'Coral Pink' }, - { color: '#a2d4d1', label: 'Mint Green' }, - { color: '#2d3436', label: 'Charcoal' }, - { color: '#6c5ce7', label: 'Purple' }, -]; - -export function AutoCarouselForm() { - const [youtubeUrl, setYoutubeUrl] = useState(''); - const [videoTitle, setVideoTitle] = useState(''); - const [numCarousels, setNumCarousels] = useState(3); - const [slidesPerCarousel, setSlidesPerCarousel] = useState(5); - const [isAnalyzing, setIsAnalyzing] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); - const [generatingIndex, setGeneratingIndex] = useState(null); - const [suggestions, setSuggestions] = useState([]); - const [generatedCarousels, setGeneratedCarousels] = useState([]); - const [urlError, setUrlError] = useState(null); - - // CTA config - const [ctaPreset, setCtaPreset] = useState('follow'); - const [ctaCustomText, setCtaCustomText] = useState(''); - const [ctaHandle, setCtaHandle] = useState('@ragtechdev'); - const [ctaPlatforms, setCtaPlatforms] = useState(['instagram', 'youtube']); - const [ctaBgColor, setCtaBgColor] = useState('#1a1a2e'); - const [includeCtaSlide, setIncludeCtaSlide] = useState(true); - - const generatedRef = useRef(null); - - // Manual mode state (local testing without API key) - const [manualModalOpen, setManualModalOpen] = useState(false); - const [manualPrompt, setManualPrompt] = useState(''); - const [manualResponse, setManualResponse] = useState(''); - const [manualMaxTimestamp, setManualMaxTimestamp] = useState(3600); - - const handleUrlChange = async (url: string) => { - setYoutubeUrl(url); - const videoId = extractVideoId(url); - if (videoId) { - setUrlError(null); - const title = await getVideoTitle(videoId); - if (title) setVideoTitle(title); - } else if (url) { - setUrlError('Valid YouTube URL or video ID is required'); - setVideoTitle(''); - } - }; - - const getCtaText = () => { - if (ctaPreset === 'custom') return ctaCustomText; - if (ctaPreset === 'follow') return `Follow us for more ${ctaHandle}`; - if (ctaPreset === 'stream') return `Stream our latest episode on by searching ${ctaHandle} on your favourite streaming platform!`; - return ''; - }; - - const handleAnalyze = async () => { - const videoId = extractVideoId(youtubeUrl); - if (!videoId) { - setUrlError('Valid YouTube URL or video ID is required'); - return; - } - - setIsAnalyzing(true); - setSuggestions([]); - setGeneratedCarousels([]); - - try { - const response = await fetch('/api/auto-carousel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ videoId, numCarousels, slidesPerCarousel }), - }); - - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Failed to analyze video'); - } - - const data = await response.json(); - - // Production mode: carousels returned directly - if (data.success && data.carousels) { - setSuggestions(data.carousels || []); - if (data.carousels.length > 0) { - notifications.show({ - title: 'Analysis Complete', - message: `Found ${data.carousels.length} carousel suggestions with ${data.carousels[0]?.slides?.length || 0} slides each`, - color: 'teal', - }); - } - return; - } - - // Manual mode: need user to paste Claude response - if (data.mode === 'manual') { - setManualPrompt(data.prompt); - setManualResponse(''); - setManualMaxTimestamp(data.maxTimestamp || 3600); - setManualModalOpen(true); - notifications.show({ - title: 'Manual Mode', - message: 'No API key configured. Copy the prompt into Claude and paste the response back.', - color: 'blue', - }); - return; - } - - notifications.show({ - title: 'No Suggestions', - message: 'Could not find suitable segments. Try a different video.', - color: 'yellow', - }); - } catch (error) { - notifications.show({ - title: 'Error', - message: error instanceof Error ? error.message : 'Failed to analyze video', - color: 'red', - }); - } finally { - setIsAnalyzing(false); - } - }; - - const handleManualSubmit = async () => { - if (!manualResponse.trim()) { - notifications.show({ title: 'Error', message: 'Please paste the Claude response', color: 'red' }); - return; - } - - setIsAnalyzing(true); - - try { - const response = await fetch('/api/auto-carousel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - step: 'build', - llmResponse: manualResponse, - maxTimestamp: manualMaxTimestamp, - }), - }); - - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Failed to build carousels'); - } - - const data = await response.json(); - - if (data.success && data.carousels) { - setSuggestions(data.carousels); - setManualModalOpen(false); - notifications.show({ - title: 'Analysis Complete', - message: `Built ${data.carousels.length} carousels from transcript`, - color: 'teal', - }); - } - } catch (error) { - notifications.show({ - title: 'Error', - message: error instanceof Error ? error.message : 'Failed to process response', - color: 'red', - }); - } finally { - setIsAnalyzing(false); - } - }; - - const handleGenerate = async (carouselIndex: number) => { - const videoId = extractVideoId(youtubeUrl); - if (!videoId) return; - - const carousel = suggestions[carouselIndex]; - if (!carousel) return; - - setGeneratingIndex(carouselIndex); - setIsGenerating(true); - - try { - const ctaConfig = includeCtaSlide - ? { - text: getCtaText(), - bgColor: ctaBgColor, - platforms: ctaPlatforms, - thumbnailUrl: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`, - } - : null; - - const response = await fetch('/api/generate-carousel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: carousel.carouselTitle.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-').toLowerCase(), - videoId, - showLogo: true, - slides: carousel.slides, - ctaSlide: ctaConfig, - }), - }); - - if (!response.ok) { - throw new Error('Failed to generate carousel'); - } - - const data = await response.json(); - const newCarousel: GeneratedCarousel = { - title: carousel.carouselTitle, - slides: data.slides || [], - }; - - setGeneratedCarousels((prev) => [...prev, newCarousel]); - - notifications.show({ - title: 'Carousel Generated!', - message: `"${carousel.carouselTitle}" — ${data.slides?.length || 0} slides`, - color: 'teal', - }); - - setTimeout(() => { - generatedRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, 300); - } catch (error) { - notifications.show({ - title: 'Error', - message: error instanceof Error ? error.message : 'Failed to generate carousel', - color: 'red', - }); - } finally { - setIsGenerating(false); - setGeneratingIndex(null); - } - }; - - const formatTimestamp = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; - }; - - const togglePlatform = (platformId: string) => { - setCtaPlatforms((prev) => - prev.includes(platformId) - ? prev.filter((p) => p !== platformId) - : [...prev, platformId] - ); - }; - - const downloadSlide = (slide: GeneratedSlide) => { - const link = document.createElement('a'); - link.href = slide.base64; - link.download = slide.filename; - link.click(); - }; - - const downloadCarouselAsZip = async (gc: GeneratedCarousel) => { - const zip = new JSZip(); - const folderName = gc.title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-').toLowerCase(); - const folder = zip.folder(folderName); - if (!folder) return; - - for (const slide of gc.slides) { - const base64Data = slide.base64.replace(/^data:image\/png;base64,/, ''); - folder.file(slide.filename, base64Data, { base64: true }); - } - - const blob = await zip.generateAsync({ type: 'blob' }); - saveAs(blob, `${folderName}.zip`); - }; - - const downloadAllAsZip = async () => { - const zip = new JSZip(); - - for (const gc of generatedCarousels) { - const folderName = gc.title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-').toLowerCase(); - const folder = zip.folder(folderName); - if (!folder) continue; - - for (const slide of gc.slides) { - const base64Data = slide.base64.replace(/^data:image\/png;base64,/, ''); - folder.file(slide.filename, base64Data, { base64: true }); - } - } - - const blob = await zip.generateAsync({ type: 'blob' }); - saveAs(blob, 'deckcreate-carousels.zip'); - }; - - return ( - - {/* Manual Mode Modal */} - setManualModalOpen(false)} - title="Manual Claude Processing" - size="xl" - closeOnClickOutside={false} - > - - - - Copy the prompt below, paste it into Claude (claude.ai), then paste Claude's JSON response back here. - - - - - - Prompt to send to Claude: - - {({ copied, copy }) => ( - - )} - - - - {manualPrompt.length > 2000 - ? manualPrompt.substring(0, 2000) + `\n\n... (${manualPrompt.length} chars total — use the Copy button above)` - : manualPrompt} - - - -