feat: add organization exercise settings management

- Created a new SQL migration to define the organization_exercise_settings table with relevant fields and an index.
- Implemented handlers for loading and updating organization exercise settings in Rust, including default values and upsert functionality.
- Developed a React component for managing exercise feature settings, allowing toggling of features and saving updates to the backend.
This commit is contained in:
2026-04-13 16:55:09 -04:00
parent 7f3e1ce9b1
commit c750ad0423
17 changed files with 899 additions and 44 deletions
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { Play, Lock, AlertCircle } from "lucide-react";
import { lmsApi, getCmsApiUrl } from "@/lib/api";
import { lmsApi, getCmsApiUrl, getImageUrl } from "@/lib/api";
interface MediaPlayerProps {
id: string;
@@ -49,7 +49,9 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
const getFullUrl = (path: string) => {
if (path.startsWith('http')) return path;
if (path.startsWith('http') || path.startsWith('s3://') || path.startsWith('org/')) {
return getImageUrl(path);
}
// Map /uploads to /assets for the backend
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
+53 -1
View File
@@ -27,7 +27,59 @@ export const getCmsApiUrl = () => {
export const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
if (path.startsWith('http')) {
// Avoid browser CORS issues with private S3 objects by proxying through CMS.
try {
const parsed = new URL(path);
const host = parsed.hostname;
const isAwsS3 = host.includes('.s3.') || host.endsWith('.amazonaws.com');
if (isAwsS3) {
const key = parsed.pathname.replace(/^\//, '');
let bucket = '';
// virtual-host style: <bucket>.s3.<region>.amazonaws.com
if (host.includes('.s3.')) {
bucket = host.split('.s3.')[0];
} else {
// path-style: s3.<region>.amazonaws.com/<bucket>/<key>
const [first, ...rest] = key.split('/');
if (first && rest.length) {
bucket = first;
const normalizedKey = rest.join('/');
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${normalizedKey}`;
}
}
if (bucket && key) {
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`;
}
}
} catch {
// Ignore URL parsing errors and fallback to original path.
}
return path;
}
// Handle persisted S3 URI format: s3://bucket/key
if (path.startsWith('s3://')) {
const withoutScheme = path.slice(5);
const firstSlash = withoutScheme.indexOf('/');
if (firstSlash > 0) {
const bucket = withoutScheme.slice(0, firstSlash);
const key = withoutScheme.slice(firstSlash + 1);
if (bucket && key) {
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`;
}
}
}
// Handle plain object keys when stored directly in DB.
if (/^org\/.+/.test(path)) {
const defaultBucket = process.env.NEXT_PUBLIC_S3_BUCKET || 'openccb-802726101181-us-east-2-an';
return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(defaultBucket)}/${path.replace(/^\//, '')}`;
}
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${getCmsApiUrl()}${finalPath}`;
+2 -2
View File
@@ -15,7 +15,7 @@ import {
Mic,
FileArchive,
Gauge,
MessageSquareQuestion
MessageSquare
} from "lucide-react";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -26,7 +26,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ icon: Building2, label: "Organizations", href: "/admin" },
{ icon: Users, label: "Users", href: "/admin/users" },
{ icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" },
{ icon: MessageSquareQuestion, label: "FAQ Moderation", href: "/admin/faq-review" },
{ icon: MessageSquare, label: "FAQ Moderation", href: "/admin/faq-review" },
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
@@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl, generateUUID } from '@/lib/api';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, OrganizationExerciseSettings, getImageUrl, generateUUID } from '@/lib/api';
import {
Layout,
CheckCircle2,
@@ -45,6 +45,17 @@ import Modal from "@/components/Modal";
import MediaPlayer from "@/components/MediaPlayer";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const defaultExerciseSettings: OrganizationExerciseSettings = {
organization_id: "",
audio_response_enabled: true,
hotspot_enabled: true,
memory_match_enabled: true,
peer_review_enabled: true,
role_playing_enabled: true,
mermaid_enabled: false,
code_lab_enabled: true,
};
const [lesson, setLesson] = useState<Lesson | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -82,6 +93,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
const [exerciseSettings, setExerciseSettings] = useState<OrganizationExerciseSettings>(defaultExerciseSettings);
const [editValue, setEditValue] = useState("");
@@ -92,8 +104,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const loadData = async () => {
try {
// Use cmsApi for consistency
const lessonData = await cmsApi.getLesson(params.lessonId);
const [lessonData, orgExerciseSettings] = await Promise.all([
cmsApi.getLesson(params.lessonId),
cmsApi.getOrganizationExerciseSettings(),
]);
setLesson(lessonData);
setExerciseSettings(orgExerciseSettings);
setSummary(lessonData.summary || "");
setIsGraded(lessonData.is_graded || false);
setSelectedCategoryId(lessonData.grading_category_id || "");
@@ -151,6 +167,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
loadData();
}, [params.id, params.lessonId]);
useEffect(() => {
if (!exerciseSettings.role_playing_enabled && aiQuizType === 'role-playing') {
setAiQuizType('multiple-choice');
}
}, [exerciseSettings.role_playing_enabled, aiQuizType]);
const handleSaveLessonTitle = async () => {
if (!lesson || !editValue) return;
try {
@@ -220,6 +242,20 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
};
const addBlock = (type: Block['type']) => {
const blockedBySettings =
(type === 'audio-response' && !exerciseSettings.audio_response_enabled) ||
(type === 'hotspot' && !exerciseSettings.hotspot_enabled) ||
(type === 'memory-match' && !exerciseSettings.memory_match_enabled) ||
(type === 'peer-review' && !exerciseSettings.peer_review_enabled) ||
(type === 'role-playing' && !exerciseSettings.role_playing_enabled) ||
(type === 'mermaid' && !exerciseSettings.mermaid_enabled) ||
(type === 'code-lab' && !exerciseSettings.code_lab_enabled);
if (blockedBySettings) {
alert('Este tipo de ejercicio está desactivado para tu organización.');
return;
}
const newBlock: Block = {
id: generateUUID(),
type,
@@ -338,6 +374,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setIsGeneratingQuiz(true);
try {
if (aiQuizType === 'role-playing') {
if (!exerciseSettings.role_playing_enabled) {
throw new Error('Role Playing está desactivado para esta organización.');
}
const data = await cmsApi.generateRolePlay(lesson.id, {
prompt_hint: aiQuizContext
});
@@ -1074,6 +1113,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
editMode={editMode}
courseId={params.id}
lessonId={params.lessonId}
aiGenerationEnabled={exerciseSettings.hotspot_enabled}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
@@ -1105,6 +1145,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
editMode={editMode}
lessonId={lesson.id}
courseId={params.id}
aiGenerationEnabled={exerciseSettings.mermaid_enabled}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
@@ -1112,6 +1153,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<RolePlayingBlock
block={block}
lessonId={params.lessonId}
aiGenerationEnabled={exerciseSettings.role_playing_enabled}
onUpdate={(updates) => updateBlock(block.id, updates)}
/>
)}
@@ -1126,6 +1168,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
test_cases={block.test_cases}
editMode={editMode}
lessonId={params.lessonId}
aiGenerationEnabled={exerciseSettings.code_lab_enabled}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
@@ -1167,12 +1210,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{ type: 'matching', icon: '🔗', label: 'Relations', color: 'violet' },
{ type: 'ordering', icon: '🔢', label: 'Sequence', color: 'blue' },
{ type: 'short-answer', icon: '💬', label: 'Open-Ended', color: 'indigo' },
{ type: 'hotspot', icon: '🎯', label: 'Hotspot', color: 'amber' },
{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' },
{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' },
{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' },
{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' },
{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' },
...(exerciseSettings.hotspot_enabled ? [{ type: 'hotspot', icon: '🎯', label: 'Hotspot', color: 'amber' }] : []),
...(exerciseSettings.audio_response_enabled ? [{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' }] : []),
...(exerciseSettings.memory_match_enabled ? [{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' }] : []),
...(exerciseSettings.peer_review_enabled ? [{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' }] : []),
...(exerciseSettings.mermaid_enabled ? [{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' }] : []),
...(exerciseSettings.role_playing_enabled ? [{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' }] : []),
...(exerciseSettings.code_lab_enabled ? [{ type: 'code-lab', icon: '💻', label: 'Code Lab', color: 'slate' }] : []),
].map((item) => (
<button
key={item.type}
@@ -1265,7 +1309,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<option value="vocabulary">Lexical Focus / Vocab</option>
<option value="grammar">Structural / Grammar Focus</option>
<option value="memory-match">Conceptual Memory Match</option>
<option value="role-playing">AI Role-Playing Simulation</option>
{exerciseSettings.role_playing_enabled && (
<option value="role-playing">AI Role-Playing Simulation</option>
)}
</select>
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
<ChevronDown size={18} />
+6 -2
View File
@@ -1,6 +1,7 @@
"use client";
import BrandingSettings from "@/components/BrandingSettings";
import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings";
import PageLayout from "@/components/PageLayout";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
@@ -22,10 +23,13 @@ export default function SettingsPage() {
return (
<PageLayout
title="Configuración de Organización"
description="Gestiona el branding y la identidad de tu plataforma."
description="Gestiona el branding y la disponibilidad de ejercicios de tu plataforma."
maxWidth="narrow"
>
<BrandingSettings />
<div className="space-y-8">
<BrandingSettings />
<ExerciseFeatureSettings />
</div>
</PageLayout>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useState } from "react";
import { cmsApi, OrganizationExerciseSettings } from "@/lib/api";
const featureCards: Array<{
key: keyof Omit<OrganizationExerciseSettings, "organization_id">;
title: string;
description: string;
}> = [
{
key: "audio_response_enabled",
title: "Audio Response",
description: "Permite ejercicios donde el alumno responde grabando audio.",
},
{
key: "hotspot_enabled",
title: "Hotspot",
description: "Permite actividades visuales con puntos interactivos sobre imágenes.",
},
{
key: "memory_match_enabled",
title: "Memory Match",
description: "Permite juegos de memoria y emparejamiento visual.",
},
{
key: "peer_review_enabled",
title: "Peer Review",
description: "Permite actividades donde estudiantes revisan entregas de otros.",
},
{
key: "role_playing_enabled",
title: "Role Playing",
description: "Permite simulaciones de conversación con IA.",
},
{
key: "mermaid_enabled",
title: "Mermaid Diagram",
description: "Permite diagramas Mermaid y su generación asistida.",
},
{
key: "code_lab_enabled",
title: "Code Lab",
description: "Permite laboratorios de código generados o editados manualmente.",
},
];
const defaultSettings: OrganizationExerciseSettings = {
organization_id: "",
audio_response_enabled: true,
hotspot_enabled: true,
memory_match_enabled: true,
peer_review_enabled: true,
role_playing_enabled: true,
mermaid_enabled: false,
code_lab_enabled: true,
};
export default function ExerciseFeatureSettings() {
const [settings, setSettings] = useState<OrganizationExerciseSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
const loadSettings = async () => {
try {
const data = await cmsApi.getOrganizationExerciseSettings();
setSettings(data);
} catch (error) {
console.error("Failed to load exercise settings:", error);
} finally {
setLoading(false);
}
};
loadSettings();
}, []);
const toggleFeature = (key: keyof Omit<OrganizationExerciseSettings, "organization_id">) => {
setSettings((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
audio_response_enabled: settings.audio_response_enabled,
hotspot_enabled: settings.hotspot_enabled,
memory_match_enabled: settings.memory_match_enabled,
peer_review_enabled: settings.peer_review_enabled,
role_playing_enabled: settings.role_playing_enabled,
mermaid_enabled: settings.mermaid_enabled,
code_lab_enabled: settings.code_lab_enabled,
};
const updated = await cmsApi.updateOrganizationExerciseSettings(payload);
setSettings(updated);
alert("Disponibilidad de ejercicios actualizada correctamente.");
} catch (error) {
console.error("Failed to update exercise settings:", error);
alert("No se pudo guardar la disponibilidad de ejercicios.");
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="p-8 text-center text-gray-400 animate-pulse">Cargando disponibilidad de ejercicios...</div>;
}
return (
<fieldset className="border border-slate-200 dark:border-white/10 rounded-2xl p-6 bg-white dark:bg-white/5 backdrop-blur-sm shadow-sm">
<legend className="px-2 text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
<span aria-hidden="true">🧩</span> Ejercicios Disponibles
</legend>
<div className="space-y-3 mt-4">
<p className="text-sm text-slate-600 dark:text-gray-400">
Activa o desactiva por organización qué tipos de ejercicios pueden usarse en el constructor de lecciones y en sus generadores asociados.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{featureCards.map((feature) => {
const enabled = settings[feature.key];
return (
<button
key={feature.key}
type="button"
onClick={() => toggleFeature(feature.key)}
className={`rounded-xl border p-4 text-left transition-all ${
enabled
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-500/10 dark:border-emerald-500/30"
: "bg-slate-50 border-slate-200 dark:bg-black/20 dark:border-white/10"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">{feature.title}</h3>
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">{feature.description}</p>
</div>
<div className={`h-7 w-12 rounded-full p-1 transition-all ${enabled ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-700"}`}>
<div className={`h-5 w-5 rounded-full bg-white transition-transform ${enabled ? "translate-x-5" : "translate-x-0"}`} />
</div>
</div>
<div className="mt-3 text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-gray-400">
{enabled ? "Activo" : "Inactivo"}
</div>
</button>
);
})}
</div>
<div className="flex justify-end mt-8">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition-all disabled:opacity-50"
>
{saving ? "Guardando..." : "Guardar disponibilidad"}
</button>
</div>
</fieldset>
);
}
@@ -14,6 +14,7 @@ interface CodeLabBlockProps {
test_cases?: { description: string; expected: string }[];
editMode: boolean;
lessonId: string;
aiGenerationEnabled?: boolean;
onChange: (updates: {
title?: string;
language?: string;
@@ -34,12 +35,17 @@ export default function CodeLabBlock({
test_cases = [],
editMode,
lessonId,
aiGenerationEnabled = true,
onChange
}: CodeLabBlockProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [promptHint, setPromptHint] = useState("");
const handleGenerateAI = async () => {
if (!aiGenerationEnabled) {
alert("Code Lab está desactivado para esta organización.");
return;
}
setIsGenerating(true);
try {
const data = await cmsApi.generateCodeLab(lessonId, {
@@ -157,15 +163,21 @@ export default function CodeLabBlock({
<div className="pt-6 border-t border-slate-100 dark:border-white/5 space-y-4">
<h4 className="text-sm font-black text-slate-800 dark:text-white uppercase tracking-tight">Generación con IA</h4>
{!aiGenerationEnabled && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">
Code Lab está desactivado para esta organización. Puedes seguir editando el bloque manualmente.
</div>
)}
<textarea
value={promptHint}
onChange={(e) => setPromptHint(e.target.value)}
placeholder="Ej. Crea un ejercicio sobre bucles for que use una lista de tareas..."
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm text-slate-700 dark:text-gray-300 min-h-[80px] outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all"
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm text-slate-700 dark:text-gray-300 min-h-[80px] outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all disabled:opacity-60"
disabled={!aiGenerationEnabled}
/>
<button
onClick={handleGenerateAI}
disabled={isGenerating}
disabled={isGenerating || !aiGenerationEnabled}
className="flex w-full justify-center items-center gap-2 px-6 py-4 bg-indigo-600 text-white rounded-2xl text-[11px] font-black uppercase tracking-widest hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-xl shadow-indigo-500/20"
>
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
@@ -23,6 +23,7 @@ interface HotspotBlockProps {
editMode: boolean;
courseId: string;
lessonId: string;
aiGenerationEnabled?: boolean;
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
}
@@ -35,6 +36,7 @@ export default function HotspotBlock({
editMode,
courseId,
lessonId,
aiGenerationEnabled = true,
onChange
}: HotspotBlockProps) {
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
@@ -42,10 +44,14 @@ export default function HotspotBlock({
const containerRef = useRef<HTMLDivElement>(null);
const handleGenerateAI = async () => {
if (!aiGenerationEnabled) {
alert("Hotspot está desactivado para esta organización.");
return;
}
if (!imageUrl) return;
setIsGenerating(true);
try {
const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl });
const data = await cmsApi.generateHotspots(lessonId, { image_url: getImageUrl(imageUrl) });
// Handle different response formats from AI
const raw: any = data;
let hotspotsArray = Array.isArray(raw) ? raw : (raw.hotspots || raw.items || []);
@@ -160,7 +166,7 @@ export default function HotspotBlock({
{imageUrl && (
<button
onClick={handleGenerateAI}
disabled={isGenerating}
disabled={isGenerating || !aiGenerationEnabled}
className="flex items-center gap-2 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-[9px] font-black uppercase tracking-widest hover:bg-amber-700 transition-all disabled:opacity-50 shadow-lg shadow-amber-500/20 active:scale-95"
>
{isGenerating ? <Loader2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
@@ -13,6 +13,7 @@ interface MermaidBlockProps {
editMode: boolean;
courseId: string;
lessonId: string;
aiGenerationEnabled?: boolean;
onChange: (updates: { title?: string; description?: string; mermaid_code?: string }) => void;
}
@@ -23,6 +24,7 @@ export default function MermaidBlock({
mermaid_code = "",
editMode,
lessonId,
aiGenerationEnabled = true,
onChange
}: MermaidBlockProps) {
const [isGenerating, setIsGenerating] = useState(false);
@@ -59,6 +61,10 @@ export default function MermaidBlock({
}, [mermaid_code, editMode]);
const handleGenerateAI = async () => {
if (!aiGenerationEnabled) {
alert("La generación de diagramas Mermaid está desactivada para esta organización.");
return;
}
setIsGenerating(true);
try {
const data = await cmsApi.generateMermaidDiagram(lessonId, { prompt_hint: promptHint || undefined });
@@ -135,17 +141,23 @@ export default function MermaidBlock({
<div className="space-y-4 pt-6 border-t border-slate-100 dark:border-white/5">
<h4 className="text-sm font-black text-slate-800 dark:text-white uppercase tracking-tight">Generación con IA</h4>
{!aiGenerationEnabled && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">
Mermaid está desactivado para esta organización. Puedes conservar o editar código existente manualmente.
</div>
)}
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Instrucciones extra (Opcional)</label>
<textarea
value={promptHint}
onChange={(e) => setPromptHint(e.target.value)}
placeholder="Ej. Crea un mapa mental sobre los conceptos clave..."
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-medium text-slate-700 dark:text-gray-300 min-h-[100px] resize-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 outline-none"
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-medium text-slate-700 dark:text-gray-300 min-h-[100px] resize-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 outline-none disabled:opacity-60"
disabled={!aiGenerationEnabled}
/>
<button
onClick={handleGenerateAI}
disabled={isGenerating}
disabled={isGenerating || !aiGenerationEnabled}
className="flex w-full justify-center items-center gap-2 px-6 py-4 bg-indigo-600 text-white rounded-2xl text-[11px] font-black uppercase tracking-widest hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-xl shadow-indigo-500/20 active:scale-95"
>
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
@@ -8,12 +8,17 @@ interface RolePlayingBlockProps {
block: Block;
onUpdate: (updates: Partial<Block>) => void;
lessonId: string;
aiGenerationEnabled?: boolean;
}
export default function RolePlayingBlock({ block, onUpdate, lessonId }: RolePlayingBlockProps) {
export default function RolePlayingBlock({ block, onUpdate, lessonId, aiGenerationEnabled = true }: RolePlayingBlockProps) {
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerateAI = async () => {
if (!aiGenerationEnabled) {
alert("Role Playing está desactivado para esta organización.");
return;
}
setIsGenerating(true);
try {
const data = await cmsApi.generateRolePlay(lessonId, {});
@@ -47,7 +52,7 @@ export default function RolePlayingBlock({ block, onUpdate, lessonId }: RolePlay
</div>
<button
onClick={handleGenerateAI}
disabled={isGenerating}
disabled={isGenerating || !aiGenerationEnabled}
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{isGenerating ? <Loader2 className="animate-spin" size={14} /> : <Wand2 size={14} />}
@@ -3,6 +3,8 @@
import { useState } from "react";
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
import MediaPlayer from "../MediaPlayer";
import FileUpload from "../FileUpload";
import { getImageUrl } from "@/lib/api";
interface VideoMarker {
timestamp: number;
@@ -15,7 +17,7 @@ interface VideoMarkerBlockProps {
title: string;
videoUrl: string;
markers: VideoMarker[];
onChange: (updates: { title?: string; markers?: VideoMarker[] }) => void;
onChange: (updates: { title?: string; url?: string; markers?: VideoMarker[] }) => void;
editMode: boolean;
isGraded?: boolean;
}
@@ -30,6 +32,13 @@ export default function VideoMarkerBlock({
}: VideoMarkerBlockProps) {
const [currentTime, setCurrentTime] = useState(0);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [sourceType, setSourceType] = useState<"url" | "upload">(
(videoUrl.startsWith("/assets/") || videoUrl.includes("/assets/") || videoUrl.startsWith("s3://") || /^org\/.+/.test(videoUrl))
? "upload"
: "url"
);
const displayVideoUrl = getImageUrl(videoUrl);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
@@ -46,6 +55,11 @@ export default function VideoMarkerBlock({
};
const addMarker = () => {
if (!videoUrl) {
alert("Primero agrega o sube un video para poder insertar marcadores.");
return;
}
const newMarker: VideoMarker = {
timestamp: currentTime,
question: "Nueva pregunta",
@@ -119,6 +133,45 @@ export default function VideoMarkerBlock({
/>
</div>
<div className="space-y-6 p-8 bg-white dark:bg-white/5 border border-indigo-500/10 dark:border-indigo-500/20 rounded-[2rem] shadow-sm">
<div className="flex items-center gap-4">
<button
onClick={() => setSourceType("url")}
className={`px-6 py-2 text-[10px] uppercase font-black tracking-[0.2em] rounded-xl transition-all border ${sourceType === "url" ? "bg-indigo-600 text-white border-indigo-600 shadow-lg shadow-indigo-500/30" : "bg-slate-50 dark:bg-white/5 text-slate-400 dark:text-gray-500 border-slate-100 hover:border-slate-200"}`}
>
External Stream
</button>
<button
onClick={() => setSourceType("upload")}
className={`px-6 py-2 text-[10px] uppercase font-black tracking-[0.2em] rounded-xl transition-all border ${sourceType === "upload" ? "bg-indigo-600 text-white border-indigo-600 shadow-lg shadow-indigo-500/30" : "bg-slate-50 dark:bg-white/5 text-slate-400 dark:text-gray-500 border-slate-100 hover:border-slate-200"}`}
>
Direct Asset
</button>
</div>
{sourceType === "url" ? (
<div className="space-y-3">
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Video Source</label>
<input
type="text"
value={videoUrl.startsWith("/") ? "" : videoUrl}
onChange={(e) => onChange({ url: e.target.value })}
placeholder="YouTube, Vimeo or direct video URL"
className="w-full bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all outline-none shadow-inner"
/>
</div>
) : (
<div className="space-y-3">
<label className="text-[10px] font-black text-slate-400 dark:text-gray-500 uppercase tracking-widest pl-1">Video Upload</label>
<FileUpload
currentUrl={videoUrl.startsWith("/") ? videoUrl : undefined}
accept="video/*"
onUploadComplete={(newUrl) => onChange({ url: newUrl })}
/>
</div>
)}
</div>
{/* Video Preview with Timeline */}
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 p-6 rounded-[3rem] space-y-6 shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/5 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2"></div>
@@ -132,7 +185,7 @@ export default function VideoMarkerBlock({
<div className="rounded-[2rem] overflow-hidden border border-slate-100 dark:border-white/10 shadow-2xl relative z-10">
<MediaPlayer
src={videoUrl}
src={displayVideoUrl}
type="video"
isGraded={isGraded}
showInteractive={false}
+14
View File
@@ -269,6 +269,17 @@ export interface BrandingResponse {
secondary_color: string;
}
export interface OrganizationExerciseSettings {
organization_id: string;
audio_response_enabled: boolean;
hotspot_enabled: boolean;
memory_match_enabled: boolean;
peer_review_enabled: boolean;
role_playing_enabled: boolean;
mermaid_enabled: boolean;
code_lab_enabled: boolean;
}
export interface User {
id: string;
email: string;
@@ -863,6 +874,9 @@ export const cmsApi = {
},
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
getOrganizationExerciseSettings: (): Promise<OrganizationExerciseSettings> => apiFetch('/organization/exercise-settings'),
updateOrganizationExerciseSettings: (payload: Omit<OrganizationExerciseSettings, 'organization_id'>): Promise<OrganizationExerciseSettings> =>
apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),
// Auth
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
File diff suppressed because one or more lines are too long