diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index c54efbb..37a4fbb 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -183,6 +183,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack value={meta} onChange={setMeta} onAutoSave={triggerImmediateAutoSave} + draftHtml={draft} /> )} diff --git a/apps/admin/src/components/steps/StepMetadata.tsx b/apps/admin/src/components/steps/StepMetadata.tsx index 61f2a94..83696e7 100644 --- a/apps/admin/src/components/steps/StepMetadata.tsx +++ b/apps/admin/src/components/steps/StepMetadata.tsx @@ -1,16 +1,22 @@ -import { Box } from '@mui/material'; +import { useState } from 'react'; +import { Box, Button, Alert, CircularProgress } from '@mui/material'; import MetadataPanel, { type Metadata } from '../MetadataPanel'; import StepHeader from './StepHeader'; +import { generateMetadata } from '../../services/ai'; export default function StepMetadata({ value, onChange, - onAutoSave + onAutoSave, + draftHtml, }: { value: Metadata; onChange: (v: Metadata) => void; onAutoSave?: () => void; + draftHtml?: string; }) { + const [generating, setGenerating] = useState(false); + const [error, setError] = useState(null); const handleChange = (v: Metadata) => { onChange(v); // Trigger auto-save after metadata change @@ -19,12 +25,57 @@ export default function StepMetadata({ } }; + const handleGenerateMetadata = async () => { + if (!draftHtml || draftHtml === '

') { + setError('Please write some content first before generating metadata.'); + return; + } + + setGenerating(true); + setError(null); + + try { + const metadata = await generateMetadata(draftHtml); + + // Update metadata, preserving feature image + const newMetadata: Metadata = { + title: metadata.title, + tagsText: metadata.tags, + canonicalUrl: metadata.canonicalUrl, + featureImage: value.featureImage, // Keep existing feature image + }; + + handleChange(newMetadata); + } catch (err: any) { + setError(err?.message || 'Failed to generate metadata'); + } finally { + setGenerating(false); + } + }; + return ( + + + + {error && ( + setError(null)}> + {error} + + )} + + ); diff --git a/apps/admin/src/services/ai.ts b/apps/admin/src/services/ai.ts index dfe22fb..3a2e270 100644 --- a/apps/admin/src/services/ai.ts +++ b/apps/admin/src/services/ai.ts @@ -16,3 +16,17 @@ export async function generateDraft(payload: { model: string; }>; } + +export async function generateMetadata(contentHtml: string) { + const res = await fetch('/api/ai/generate-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentHtml }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise<{ + title: string; + tags: string; + canonicalUrl: string; + }>; +} diff --git a/apps/api/src/ai-generate.ts b/apps/api/src/ai-generate.ts index f91ee4d..11e8591 100644 --- a/apps/api/src/ai-generate.ts +++ b/apps/api/src/ai-generate.ts @@ -135,4 +135,91 @@ router.post('/generate', async (req, res) => { } }); +// Generate metadata (title, tags, canonical URL) from content +router.post('/generate-metadata', async (req, res) => { + try { + const { contentHtml } = req.body as { contentHtml: string }; + + if (!contentHtml) { + return res.status(400).json({ error: 'contentHtml is required' }); + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return res.status(500).json({ error: 'OpenAI API key not configured' }); + } + + const openai = new OpenAI({ apiKey }); + + // Strip HTML tags for better analysis + const textContent = contentHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const preview = textContent.slice(0, 2000); // First 2000 chars + + console.log('[AI Metadata] Generating metadata...'); + + const completion = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: [ + { + role: 'system', + content: `You are an SEO expert. Generate metadata for blog posts. + +REQUIREMENTS: +1. Title: Compelling, SEO-friendly, 50-60 characters +2. Tags: 3-5 relevant tags, comma-separated +3. Canonical URL: SEO-friendly slug based on title (lowercase, hyphens, no special chars) + +OUTPUT FORMAT (JSON): +{ + "title": "Your Compelling Title Here", + "tags": "tag1, tag2, tag3", + "canonicalUrl": "your-seo-friendly-slug" +} + +Return ONLY valid JSON, no markdown, no explanation.`, + }, + { + role: 'user', + content: `Generate metadata for this article:\n\n${preview}`, + }, + ], + temperature: 0.7, + max_tokens: 300, + }); + + const response = completion.choices[0]?.message?.content || ''; + + if (!response) { + return res.status(500).json({ error: 'No metadata generated' }); + } + + console.log('[AI Metadata] Raw response:', response); + + // Parse JSON response + let metadata; + try { + // Remove markdown code blocks if present + const cleaned = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + metadata = JSON.parse(cleaned); + } catch (parseErr) { + console.error('[AI Metadata] JSON parse error:', parseErr); + return res.status(500).json({ error: 'Failed to parse metadata response' }); + } + + console.log('[AI Metadata] Generated:', metadata); + + return res.json({ + title: metadata.title || '', + tags: metadata.tags || '', + canonicalUrl: metadata.canonicalUrl || '', + }); + } catch (err: any) { + console.error('[AI Metadata] Error:', err); + return res.status(500).json({ + error: 'Metadata generation failed', + details: err?.message || 'Unknown error' + }); + } +}); + export default router;