feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages
This commit is contained in:
226
src/components/admin/ContentManager.jsx
Normal file
226
src/components/admin/ContentManager.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
RefreshCw, Wand2, Trash2, CheckCircle, Loader,
|
||||
AlertCircle, BookOpen, ArrowLeft, Eye
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService';
|
||||
import LearningContentViewer from '../ui/LearningContentViewer';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
|
||||
const ContentManager = () => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState(null); // { topic, content } — the open detail view
|
||||
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } }
|
||||
const [refineText, setRefineText] = useState('');
|
||||
|
||||
const refresh = () => {
|
||||
const fresh = getAllGeneratedContent();
|
||||
setItems(fresh);
|
||||
// Keep detail view in sync if its topic was updated
|
||||
if (selected) {
|
||||
const updated = fresh.find(i => i.topic.id === selected.topic.id);
|
||||
if (updated) setSelected(updated);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
const setTopicState = (id, patch) =>
|
||||
setActionState(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
||||
|
||||
const handleRegenerate = async (topic) => {
|
||||
setTopicState(topic.id, { loading: 'regenerating', error: null, success: null });
|
||||
try {
|
||||
await generateLearningContent(topic, true);
|
||||
setTopicState(topic.id, { loading: null, success: 'Content regenerated.' });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setTopicState(topic.id, { loading: null, error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefine = async (topic) => {
|
||||
const instruction = refineText.trim();
|
||||
if (!instruction) return;
|
||||
setTopicState(topic.id, { loading: 'refining', error: null, success: null });
|
||||
try {
|
||||
await refineLearningContent(topic, instruction);
|
||||
setTopicState(topic.id, { loading: null, success: 'Content refined.' });
|
||||
setRefineText('');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setTopicState(topic.id, { loading: null, error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (topicId) => {
|
||||
deleteCachedContent(topicId);
|
||||
if (selected?.topic.id === topicId) setSelected(null);
|
||||
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
|
||||
refresh();
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-fg-muted">
|
||||
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
|
||||
<p className="font-medium">No generated content yet.</p>
|
||||
<p className="text-sm mt-1">Upload sources then visit the Learn page to generate content.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail view ──────────────────────────────────────────
|
||||
if (selected) {
|
||||
const { topic, content } = selected;
|
||||
const state = actionState[topic.id] || {};
|
||||
const isLoading = !!state.loading;
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
{/* Back + header */}
|
||||
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to all content
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-2xl font-bold">{topic.label}</h2>
|
||||
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
|
||||
</div>
|
||||
<p className="text-fg-muted text-sm mt-1">{topic.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRegenerate(topic)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{state.loading === 'regenerating'
|
||||
? <Loader size={16} className="mr-2 animate-spin" />
|
||||
: <RefreshCw size={16} className="mr-2" />}
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
disabled={isLoading}
|
||||
className="border-red-200 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{(state.success || state.error) && (
|
||||
<div className={`mb-6 p-3 rounded-[var(--r-sm)] text-sm flex items-center gap-2 ${state.error ? 'bg-red-50 text-red-800' : 'bg-teal-50 text-teal-800'}`}>
|
||||
{state.error ? <AlertCircle size={16} /> : <CheckCircle size={16} />}
|
||||
{state.error || state.success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full content preview */}
|
||||
<LearningContentViewer content={content} topic={topic} />
|
||||
|
||||
{/* Refine panel */}
|
||||
<Card className="border border-teal/30 mt-8">
|
||||
<h3 className="font-bold text-lg mb-1 flex items-center gap-2">
|
||||
<Wand2 size={18} className="text-teal" /> Refine with AI
|
||||
</h3>
|
||||
<p className="text-sm text-fg-muted mb-4">
|
||||
Review the content above, then describe what should change. Be specific — the AI will apply your instruction and update all content formats.
|
||||
</p>
|
||||
<textarea
|
||||
value={refineText}
|
||||
onChange={(e) => setRefineText(e.target.value)}
|
||||
placeholder='e.g. "Make the article more beginner-friendly and add a real-world example to each slide."'
|
||||
rows={4}
|
||||
className="w-full p-3 rounded-[var(--r-sm)] border border-bg-warm bg-bg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal/30"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button
|
||||
onClick={() => handleRefine(topic)}
|
||||
disabled={isLoading || !refineText.trim()}
|
||||
>
|
||||
{state.loading === 'refining'
|
||||
? <><Loader size={16} className="mr-2 animate-spin" /> Refining...</>
|
||||
: <><Wand2 size={16} className="mr-2" /> Apply Refinement</>}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── List view ────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map(({ topic, content }) => {
|
||||
const state = actionState[topic.id] || {};
|
||||
const isLoading = !!state.loading;
|
||||
|
||||
return (
|
||||
<Card key={topic.id} className="border border-bg-warm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold">{topic.label}</span>
|
||||
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
|
||||
<Tag variant="success" className="text-xs">Ready</Tag>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted mt-0.5 truncate">{topic.description}</p>
|
||||
{(state.success || state.error) && (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${state.error ? 'text-red-600' : 'text-teal-700'}`}>
|
||||
{state.error ? <AlertCircle size={12} /> : <CheckCircle size={12} />}
|
||||
{state.error || state.success}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Quick actions */}
|
||||
<button
|
||||
onClick={() => handleRegenerate(topic)}
|
||||
disabled={isLoading}
|
||||
title="Regenerate from scratch"
|
||||
className="p-2 rounded-[var(--r-sm)] hover:bg-bg-warm transition-colors text-fg-muted hover:text-teal disabled:opacity-40"
|
||||
>
|
||||
{state.loading === 'regenerating' ? <Loader size={17} className="animate-spin" /> : <RefreshCw size={17} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
disabled={isLoading}
|
||||
title="Delete cached content"
|
||||
className="p-2 rounded-[var(--r-sm)] hover:bg-red-50 transition-colors text-fg-muted hover:text-red-500 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
|
||||
{/* Review button */}
|
||||
<Button
|
||||
onClick={() => setSelected({ topic, content })}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye size={16} /> Review
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentManager;
|
||||
Reference in New Issue
Block a user