Files
openccb/web/studio/src/app/page.tsx
T

345 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { cmsApi, Course, Organization } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { Plus, BookOpen, Download, Upload, Sparkles, Wand2, Trash2 } from "lucide-react";
import OrganizationSelector from "@/components/OrganizationSelector";
import Modal from "@/components/Modal";
import PageLayout from "@/components/PageLayout";
export default function StudioDashboard() {
const { t } = useTranslation();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
useEffect(() => {
const loadCourses = async () => {
if (!user) {
setLoading(false);
return;
};
try {
const data = await cmsApi.getCourses();
setCourses(data);
} catch (err) {
console.error("Failed to load courses", err);
} finally {
setLoading(false);
}
};
loadCourses();
}, [user]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
const [isAIModalOpen, setIsAIModalOpen] = useState(false);
const [newCourseTitle, setNewCourseTitle] = useState("");
const [aiPrompt, setAiPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
const loadOrgs = async () => {
if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') {
try {
const orgs = await cmsApi.getOrganizations();
setOrganizations(orgs);
} catch (err) {
console.error("Failed to load organizations", err);
}
}
};
loadOrgs();
}, [user]);
const handleCreateCourse = async () => {
setIsTitleModalOpen(true);
};
const onTitleConfirm = (e: React.FormEvent) => {
e.preventDefault();
if (!newCourseTitle) return;
setIsTitleModalOpen(false);
const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001';
if (isSuperAdmin && organizations.length > 0) {
setIsOrgModalOpen(true);
} else {
createCourse();
}
};
const createCourse = async (targetOrgId?: string) => {
try {
const newCourse = await cmsApi.createCourse(newCourseTitle, targetOrgId);
setCourses((prev: Course[]) => [...prev, newCourse]);
setNewCourseTitle("");
} catch (err) {
console.error("Failed to create course", err);
alert("Failed to create course. Please ensure the backend is running.");
}
};
const handleAIGenerate = async (e: React.FormEvent) => {
e.preventDefault();
if (!aiPrompt) return;
setIsGenerating(true);
try {
const newCourse = await cmsApi.generateCourse(aiPrompt);
setCourses((prev: Course[]) => [...prev, newCourse]);
setAiPrompt("");
setIsAIModalOpen(false);
alert("Course generated successfully with AI!");
} catch (err) {
console.error("AI generation failed", err);
alert("Failed to generate course with AI. Check backend logs and AI provider status.");
} finally {
setIsGenerating(false);
}
};
const handleExport = async (e: React.MouseEvent, courseId: string, title: string) => {
e.preventDefault();
e.stopPropagation();
try {
const data = await cmsApi.exportCourse(courseId);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `course_${title.replace(/\s+/g, '_')}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (err) {
console.error("Export failed", err);
alert("Failed to export course");
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const json = JSON.parse(event.target?.result as string);
const newCourse = await cmsApi.importCourse(json);
setCourses((prev: Course[]) => [...prev, newCourse]);
alert("Course imported successfully!");
} catch (err) {
console.error("Import failed", err);
alert("Failed to import course. Ensure the file is a valid OpenCCB course export.");
}
};
reader.readAsText(file);
};
const handleDeleteCourse = async (e: React.MouseEvent, courseId: string, title: string) => {
e.preventDefault();
e.stopPropagation();
if (!confirm(t('confirm_delete_course') || `Are you sure you want to delete "${title}"? This action cannot be undone.`)) {
return;
}
try {
await cmsApi.deleteCourse(courseId);
setCourses(prev => prev.filter(c => c.id !== courseId));
} catch (err) {
console.error("Delete failed", err);
alert("Failed to delete course");
}
};
return (
<PageLayout
title={t('nav.courses')}
description={t('dashboard.title') || 'Panel de Control'}
actions={
<>
<label className="flex items-center gap-2 px-5 py-2.5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl font-bold text-sm transition-all cursor-pointer active:scale-95 text-slate-900 dark:text-white">
<Upload size={16} />
Importar
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
</label>
<button
onClick={() => setIsAIModalOpen(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-500/20 transition-all active:scale-95 group"
>
<Sparkles size={16} className="text-amber-300 group-hover:rotate-12 transition-transform" />
AI Wizard
</button>
<button
onClick={handleCreateCourse}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl font-bold text-sm transition-all active:scale-95 text-slate-900 dark:text-white"
>
<Plus size={18} />
Manual
</button>
</>
}
>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 glass-card animate-pulse bg-white/5 border-white/5"></div>
))}
</div>
) : courses.length === 0 ? (
<div className="text-center py-20 glass-card border-dashed border-black/10 dark:border-white/10">
<p className="text-gray-500 dark:text-gray-400">Aún no has creado ningún curso.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course: Course) => (
<Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center">
<BookOpen className="text-blue-600 dark:text-blue-400" />
</div>
<button
onClick={(e) => handleExport(e, course.id, course.title)}
className="p-2 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-white transition-all"
title="Export Course"
>
<Download size={18} />
</button>
<button
onClick={(e) => handleDeleteCourse(e, course.id, course.title)}
className="p-2 hover:bg-red-500/10 rounded-lg text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-all"
title="Delete Course"
>
<Trash2 size={18} />
</button>
</div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-gray-900 dark:text-white">{course.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{course.description || "Sin descripción disponible."}</p>
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-100 dark:border-white/5 text-xs text-slate-500 dark:text-gray-400">
<span>Última actualización: {new Date(course.updated_at).toLocaleDateString()}</span>
<span>ID: {course.id.slice(0, 4)}...</span>
</div>
</div>
</Link>
))}
</div>
)}
{/* New Course Title Modal */}
<Modal
isOpen={isTitleModalOpen}
onClose={() => setIsTitleModalOpen(false)}
title="Create New Course"
>
<form onSubmit={onTitleConfirm} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Course Title
</label>
<input
autoFocus
required
type="text"
value={newCourseTitle}
onChange={(e) => setNewCourseTitle(e.target.value)}
placeholder="e.g. Advanced Rust Development"
className="w-full bg-black/5 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-gray-900 dark:text-white"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setIsTitleModalOpen(false)}
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-[2] px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold text-sm"
>
Next
</button>
</div>
</form>
</Modal>
{/* AI Wizard Modal */}
<Modal
isOpen={isAIModalOpen}
onClose={() => !isGenerating && setIsAIModalOpen(false)}
title="AI Course Wizard"
>
<form onSubmit={handleAIGenerate} className="space-y-6">
<div className="p-4 rounded-xl bg-indigo-500/5 border border-indigo-500/10 mb-2">
<p className="text-xs text-indigo-300 leading-relaxed font-medium">
Describe the course topic and target audience. Our AI will structure the modules and lessons for you in seconds.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Course Topic or Description
</label>
<textarea
autoFocus
required
rows={4}
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
placeholder="e.g. A comprehensive guide to building distributed systems with Rust and Axum for intermediate developers."
className="w-full bg-black/5 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all text-gray-900 dark:text-white resize-none"
disabled={isGenerating}
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setIsAIModalOpen(false)}
disabled={isGenerating}
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={isGenerating}
className="flex-[2] px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-all shadow-lg shadow-indigo-500/20 font-bold text-sm flex items-center justify-center gap-2"
>
{isGenerating ? (
<>
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
Generating...
</>
) : (
<>
<Wand2 size={16} />
Magic Create
</>
)}
</button>
</div>
</form>
</Modal>
{/* Organization Selector Modal */}
<OrganizationSelector
isOpen={isOrgModalOpen}
onClose={() => setIsOrgModalOpen(false)}
organizations={organizations}
title="Target Organization"
actionLabel="Create Course"
onConfirm={(orgId) => createCourse(orgId)}
/>
</PageLayout>
);
}