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 (
+
+
+
'}
+ startIcon={generating ? : null}
+ >
+ {generating ? 'Generating...' : 'Generate Metadata with AI'}
+
+ {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;