345 lines
14 KiB
TypeScript
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>
|
|
);
|
|
} |