feat: i18n full support, responsive UI, multi-model AI config, and bug fixes
Major Features:
- Internationalization (i18n) with auto-detection for ES/EN/PT
- Mobile-first responsive design for Studio and Experience
- Multi-model AI configuration (llama3.2:3b, qwen3.5:9b, gpt-oss:latest)
- Course language configuration (auto-detect or fixed per course)
Backend Changes:
- shared/common: ModelType enum for intelligent model selection
- LMS: log_ai_usage function migration (fix chat tutor 500 error)
- LMS/CMS: course language config fields (language_setting, fixed_language)
- LMS: /courses/{id}/language-config endpoint for language detection
Frontend Changes:
- Experience: Enhanced i18n with browser language detection
- Experience: Audio recording with HTTPS check and error handling
- Studio: Memory game with unique pair IDs and debug logging
- Studio: Expanded translations (250+ keys for ES, EN, PT)
- Both: Language selector in headers (mobile responsive)
Documentation:
- AI_MODELS_CONFIG.md: Multi-model configuration guide
- RESPONSIVIDAD_GUIA.md: Mobile-first design patterns
- I18N_RESPONSIVIDAD_IMPLEMENTACION.md: Implementation details
- DEBUG_AUDIO_RECORDING.md: Audio troubleshooting guide
- DEBUG_MEMORY_GAME.md: Memory game debugging steps
Bug Fixes:
- Fix chat tutor 500 error (missing log_ai_usage function)
- Fix audio recording (HTTPS check, browser compatibility)
- Fix memory game pair IDs (unique ID generation)
- Fix HotspotBlock TypeScript errors
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -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 } from '@/lib/api';
|
||||
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl, generateUUID } from '@/lib/api';
|
||||
import {
|
||||
Layout,
|
||||
CheckCircle2,
|
||||
@@ -103,7 +103,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
setImportantDateType(lessonData.important_date_type || "");
|
||||
setIsPreviewable(lessonData.is_previewable || false);
|
||||
|
||||
if (lessonData.metadata?.blocks) {
|
||||
if (lessonData.content_blocks && Array.isArray(lessonData.content_blocks)) {
|
||||
setBlocks(lessonData.content_blocks);
|
||||
} else if (lessonData.metadata?.blocks) {
|
||||
setBlocks(lessonData.metadata.blocks);
|
||||
} else {
|
||||
setBlocks([
|
||||
@@ -195,7 +197,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
}
|
||||
|
||||
const updated = await cmsApi.updateLesson(lesson.id, {
|
||||
metadata: { ...lesson.metadata, blocks },
|
||||
metadata: { ...lesson.metadata },
|
||||
content_blocks: blocks,
|
||||
content_url,
|
||||
content_type, // Sync type to ensure backend triggers transcription
|
||||
summary,
|
||||
@@ -218,7 +221,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
|
||||
const addBlock = (type: Block['type']) => {
|
||||
const newBlock: Block = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
type,
|
||||
...(type === 'description' && { content: "" }),
|
||||
...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair, Wand2, Loader2 } from "lucide-react";
|
||||
import AssetPickerModal from "../AssetPickerModal";
|
||||
import { Asset, getImageUrl, cmsApi } from "@/lib/api";
|
||||
import { Asset, getImageUrl, cmsApi, generateUUID } from "@/lib/api";
|
||||
|
||||
interface Hotspot {
|
||||
id: string;
|
||||
@@ -46,12 +46,19 @@ export default function HotspotBlock({
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl });
|
||||
const newHotspots = data.map(h => ({
|
||||
id: crypto.randomUUID(),
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
// Handle different response formats from AI
|
||||
let hotspotsArray = Array.isArray(data) ? data : (data.hotspots || data.items || []);
|
||||
|
||||
if (!Array.isArray(hotspotsArray)) {
|
||||
throw new Error("La respuesta de la IA no es un array válido");
|
||||
}
|
||||
|
||||
const newHotspots = hotspotsArray.map((h: any) => ({
|
||||
id: generateUUID(),
|
||||
x: typeof h.x === 'number' ? h.x : 50,
|
||||
y: typeof h.y === 'number' ? h.y : 50,
|
||||
radius: 5,
|
||||
label: h.label
|
||||
label: h.label || 'Punto de interés'
|
||||
}));
|
||||
onChange({ hotspots: [...hotspots, ...newHotspots] });
|
||||
} catch (error) {
|
||||
@@ -76,7 +83,7 @@ export default function HotspotBlock({
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
|
||||
const newHotspot: Hotspot = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
x,
|
||||
y,
|
||||
radius: 5,
|
||||
|
||||
@@ -16,27 +16,38 @@ interface MemoryBlockProps {
|
||||
onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void;
|
||||
}
|
||||
|
||||
// Generate unique ID for pairs
|
||||
const generatePairId = () => {
|
||||
return `pair_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
export default function MemoryBlock({ id, title, pairs = [], editMode, onChange }: MemoryBlockProps) {
|
||||
const addPair = () => {
|
||||
const newPair: MatchingPair = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
id: generatePairId(),
|
||||
left: "",
|
||||
right: ""
|
||||
};
|
||||
console.log('[MemoryBlock] Adding new pair:', newPair);
|
||||
onChange({ pairs: [...pairs, newPair] });
|
||||
};
|
||||
|
||||
const updatePair = (index: number, updates: Partial<MatchingPair>) => {
|
||||
const newPairs = [...pairs];
|
||||
newPairs[index] = { ...newPairs[index], ...updates };
|
||||
console.log('[MemoryBlock] Updating pair at index', index, ':', updates);
|
||||
onChange({ pairs: newPairs });
|
||||
};
|
||||
|
||||
const removePair = (index: number) => {
|
||||
console.log('[MemoryBlock] Removing pair at index', index);
|
||||
const newPairs = pairs.filter((_, i) => i !== index);
|
||||
onChange({ pairs: newPairs });
|
||||
};
|
||||
|
||||
// Debug: Log pairs on render
|
||||
console.log('[MemoryBlock] Render with pairs:', pairs);
|
||||
|
||||
if (!editMode) {
|
||||
return (
|
||||
<div className="space-y-8" id={id}>
|
||||
|
||||
@@ -8,29 +8,64 @@ import pt from '../lib/locales/pt.json';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const translations: Record<string, any> = { en, es, pt };
|
||||
|
||||
// Idiomas soportados
|
||||
export const SUPPORTED_LANGUAGES = ['es', 'en', 'pt'] as const;
|
||||
export type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
|
||||
|
||||
interface I18nContextType {
|
||||
language: string;
|
||||
setLanguage: (lang: string) => void;
|
||||
t: (path: string) => string;
|
||||
detectBrowserLanguage: () => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Detecta el idioma del navegador del usuario
|
||||
*/
|
||||
function detectBrowserLanguage(): string {
|
||||
if (typeof navigator === 'undefined') return 'es';
|
||||
|
||||
const browserLanguages = navigator.languages || [navigator.language || (navigator as any).userLanguage];
|
||||
|
||||
for (const browserLang of browserLanguages) {
|
||||
if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) {
|
||||
return browserLang;
|
||||
}
|
||||
|
||||
const langCode = browserLang.split('-')[0].toLowerCase();
|
||||
if (SUPPORTED_LANGUAGES.includes(langCode as SupportedLanguage)) {
|
||||
return langCode;
|
||||
}
|
||||
}
|
||||
|
||||
return 'es';
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguageState] = useState('es');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedLang = localStorage.getItem('studio_language');
|
||||
if (savedLang) {
|
||||
|
||||
if (savedLang && SUPPORTED_LANGUAGES.includes(savedLang as SupportedLanguage)) {
|
||||
setLanguageState(savedLang);
|
||||
} else {
|
||||
// Try to detect from user profile if available, but for now default or localstorage
|
||||
const detectedLang = detectBrowserLanguage();
|
||||
setLanguageState(detectedLang);
|
||||
localStorage.setItem('studio_language', detectedLang);
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const setLanguage = (lang: string) => {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem('studio_language', lang);
|
||||
if (SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)) {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem('studio_language', lang);
|
||||
}
|
||||
};
|
||||
|
||||
const t = (path: string): string => {
|
||||
@@ -41,7 +76,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
if (result[key]) {
|
||||
result = result[key];
|
||||
} else {
|
||||
return path; // Fallback to path if key missing
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +84,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ language, setLanguage, t }}>
|
||||
<I18nContext.Provider value={{ language, setLanguage, t, detectBrowserLanguage }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
@@ -62,3 +97,5 @@ export function useTranslation() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { detectBrowserLanguage };
|
||||
|
||||
@@ -11,6 +11,19 @@ const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
||||
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
||||
export const LMS_API_BASE_URL = getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL);
|
||||
|
||||
// Polyfill for crypto.randomUUID() for non-HTTPS contexts
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for non-secure contexts
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
|
||||
@@ -1,39 +1,253 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"back": "Volver",
|
||||
"next": "Siguiente",
|
||||
"finish": "Finalizar",
|
||||
"error": "Error",
|
||||
"retry": "Reintentar",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"close": "Cerrar",
|
||||
"confirm": "Confirmar",
|
||||
"success": "Éxito",
|
||||
"warning": "Advertencia",
|
||||
"create": "Crear",
|
||||
"loading": "Cargando...",
|
||||
"add": "Agregar",
|
||||
"remove": "Eliminar",
|
||||
"update": "Actualizar",
|
||||
"search": "Buscar",
|
||||
"back": "Volver",
|
||||
"next": "Siguiente",
|
||||
"filter": "Filtrar",
|
||||
"export": "Exportar",
|
||||
"import": "Importar",
|
||||
"download": "Descargar",
|
||||
"upload": "Subir",
|
||||
"preview": "Vista Previa",
|
||||
"publish": "Publicar",
|
||||
"preview": "Previsualizar"
|
||||
"draft": "Borrador",
|
||||
"archive": "Archivar",
|
||||
"duplicate": "Duplicar",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"courses": "Cursos",
|
||||
"globalControl": "Control Global",
|
||||
"webhooks": "Webhooks",
|
||||
"library": "Librería",
|
||||
"questionBank": "Banco de Preguntas",
|
||||
"testTemplates": "Plantillas de Pruebas",
|
||||
"analytics": "Analíticas",
|
||||
"students": "Estudiantes",
|
||||
"instructors": "Instructores",
|
||||
"settings": "Configuración",
|
||||
"profile": "Perfil",
|
||||
"signOut": "Cerrar Sesión"
|
||||
"signOut": "Cerrar Sesión",
|
||||
"selectLanguage": "Seleccionar Idioma",
|
||||
"controlPanel": "Panel de Control",
|
||||
"webhooks": "Webhooks",
|
||||
"globalControl": "Control Global"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de Control",
|
||||
"newCourse": "Nuevo Curso",
|
||||
"aiBuilder": "Constructor AI",
|
||||
"noCourses": "No hay cursos disponibles."
|
||||
"course": {
|
||||
"createCourse": "Crear Curso",
|
||||
"editCourse": "Editar Curso",
|
||||
"deleteCourse": "Eliminar Curso",
|
||||
"courseTitle": "Título del Curso",
|
||||
"courseDescription": "Descripción del Curso",
|
||||
"courseLanguage": "Idioma del Curso",
|
||||
"languageAuto": "Automático (detectar del usuario)",
|
||||
"languageFixed": "Fijo (siempre este idioma)",
|
||||
"languageEnglish": "Inglés",
|
||||
"languageSpanish": "Español",
|
||||
"languagePortuguese": "Portugués",
|
||||
"modules": "Módulos",
|
||||
"addModule": "Agregar Módulo",
|
||||
"editModule": "Editar Módulo",
|
||||
"deleteModule": "Eliminar Módulo",
|
||||
"moduleName": "Nombre del Módulo",
|
||||
"moduleDescription": "Descripción del Módulo",
|
||||
"lessons": "Lecciones",
|
||||
"addLesson": "Agregar Lección",
|
||||
"editLesson": "Editar Lección",
|
||||
"deleteLesson": "Eliminar Lección",
|
||||
"lessonTitle": "Título de la Lección",
|
||||
"lessonType": "Tipo de Lección",
|
||||
"blocks": "Bloques",
|
||||
"addBlock": "Agregar Bloque",
|
||||
"blockType": "Tipo de Bloque",
|
||||
"reorderBlocks": "Reordenar Bloques",
|
||||
"moveUp": "Mover Arriba",
|
||||
"moveDown": "Mover Abajo",
|
||||
"pricing": "Precios",
|
||||
"currency": "Moneda",
|
||||
"enrollment": "Inscripciones",
|
||||
"published": "Publicado",
|
||||
"unpublished": "No Publicado",
|
||||
"visibility": "Visibilidad",
|
||||
"public": "Público",
|
||||
"private": "Privado"
|
||||
},
|
||||
"editor": {
|
||||
"outline": "Estructura",
|
||||
"settings": "Configuración",
|
||||
"newModule": "Nuevo Módulo",
|
||||
"newLesson": "Nueva Lección",
|
||||
"addBlock": "Añadir Bloque de Contenido",
|
||||
"publishToOrg": "Publicar en Organización",
|
||||
"reading": "Lectura"
|
||||
"content": {
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"text": "Texto",
|
||||
"image": "Imagen",
|
||||
"document": "Documento",
|
||||
"quiz": "Cuestionario",
|
||||
"codeExercise": "Ejercicio de Código",
|
||||
"memoryGame": "Juego de Memoria",
|
||||
"dragToCube": "Arrastrar al Cubo",
|
||||
"hotspots": "Puntos Calientes",
|
||||
"videoMarkers": "Marcadores de Video",
|
||||
"audioResponse": "Respuesta de Audio",
|
||||
"fillBlanks": "Completar Espacios",
|
||||
"matching": "Emparejamiento",
|
||||
"ordering": "Ordenamiento",
|
||||
"shortAnswer": "Respuesta Corta",
|
||||
"multipleChoice": "Opción Múltiple",
|
||||
"trueFalse": "Verdadero/Falso",
|
||||
"essay": "Ensayo",
|
||||
"uploadMedia": "Subir Archivo",
|
||||
"dropFiles": "Soltar archivos aquí",
|
||||
"browseFiles": "Examinar archivos",
|
||||
"uploading": "Subiendo...",
|
||||
"uploadComplete": "Subida Completa",
|
||||
"uploadFailed": "Subida Fallida"
|
||||
},
|
||||
"ai": {
|
||||
"generateWithAI": "Generar con IA",
|
||||
"generateCourse": "Generar Curso con IA",
|
||||
"generateQuiz": "Generar Cuestionario con IA",
|
||||
"generateSummary": "Generar Resumen con IA",
|
||||
"generateTranscription": "Transcribir con IA",
|
||||
"generateQuestions": "Generar Preguntas con IA",
|
||||
"topicPrompt": "Ingresa el tema del curso",
|
||||
"generating": "Generando...",
|
||||
"generationComplete": "Generación Completa",
|
||||
"generationFailed": "Generación Fallida",
|
||||
"aiPowered": "Potenciado por IA",
|
||||
"model": "Modelo de IA",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Costo"
|
||||
},
|
||||
"grading": {
|
||||
"gradingSystem": "Sistema de Calificación",
|
||||
"categories": "Categorías",
|
||||
"weights": "Pesos",
|
||||
"passingGrade": "Nota de Aprobación",
|
||||
"percentage": "Porcentaje",
|
||||
"dropLowest": "Eliminar Notas Más Bajas",
|
||||
"maxAttempts": "Intentos Máximos",
|
||||
"autoGrade": "Calificación Automática",
|
||||
"manualGrade": "Calificación Manual",
|
||||
"rubric": "Rúbrica",
|
||||
"feedback": "Retroalimentación",
|
||||
"grades": "Notas",
|
||||
"exportGrades": "Exportar Notas",
|
||||
"importGrades": "Importar Notas"
|
||||
},
|
||||
"students": {
|
||||
"enrollments": "Inscripciones",
|
||||
"enrollStudent": "Inscribir Estudiante",
|
||||
"bulkEnroll": "Inscripción Masiva",
|
||||
"studentProgress": "Progreso del Estudiante",
|
||||
"studentGrades": "Notas del Estudiante",
|
||||
"studentActivity": "Actividad del Estudiante",
|
||||
"cohort": "Cohorte",
|
||||
"groups": "Grupos",
|
||||
"retention": "Retención",
|
||||
"dropoutRisk": "Riesgo de Abandono",
|
||||
"engagement": "Compromiso"
|
||||
},
|
||||
"analytics": {
|
||||
"overview": "Resumen",
|
||||
"enrollments": "Inscripciones",
|
||||
"completions": "Completaciones",
|
||||
"averageGrade": "Nota Promedio",
|
||||
"engagement": "Compromiso",
|
||||
"retention": "Retención",
|
||||
"revenue": "Ingresos",
|
||||
"aiUsage": "Uso de IA",
|
||||
"exportReport": "Exportar Reporte",
|
||||
"dateRange": "Rango de Fechas",
|
||||
"compareCourses": "Comparar Cursos",
|
||||
"studentPerformance": "Rendimiento Estudiantil"
|
||||
},
|
||||
"settings": {
|
||||
"general": "Configuración General",
|
||||
"branding": "Marca",
|
||||
"logo": "Logotipo",
|
||||
"favicon": "Favicon",
|
||||
"colors": "Colores",
|
||||
"platformName": "Nombre de la Plataforma",
|
||||
"notifications": "Notificaciones",
|
||||
"emailTemplates": "Plantillas de Email",
|
||||
"integrations": "Integraciones",
|
||||
"lti": "LTI",
|
||||
"sso": "SSO",
|
||||
"webhooks": "Webhooks",
|
||||
"apiKeys": "Claves API",
|
||||
"security": "Seguridad",
|
||||
"backup": "Respaldo",
|
||||
"export": "Exportar Datos",
|
||||
"import": "Importar Datos"
|
||||
},
|
||||
"user": {
|
||||
"profile": "Perfil",
|
||||
"editProfile": "Editar Perfil",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"preferences": "Preferencias",
|
||||
"language": "Idioma",
|
||||
"timezone": "Zona Horaria",
|
||||
"notifications": "Notificaciones",
|
||||
"avatar": "Avatar",
|
||||
"bio": "Biografía",
|
||||
"role": "Rol",
|
||||
"admin": "Administrador",
|
||||
"instructor": "Instructor",
|
||||
"student": "Estudiante"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Campo requerido",
|
||||
"invalidEmail": "Email inválido",
|
||||
"minLength": "Longitud mínima: {{length}}",
|
||||
"maxLength": "Longitud máxima: {{length}}",
|
||||
"invalidFormat": "Formato inválido",
|
||||
"alreadyExists": "Ya existe",
|
||||
"notFound": "No encontrado",
|
||||
"unauthorized": "No autorizado",
|
||||
"serverError": "Error del servidor",
|
||||
"networkError": "Error de red",
|
||||
"uploadFailed": "Subida fallida",
|
||||
"generationFailed": "Generación fallida"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Este campo es requerido",
|
||||
"email": "Debe ser un email válido",
|
||||
"url": "Debe ser una URL válida",
|
||||
"number": "Debe ser un número",
|
||||
"min": "Debe ser mayor o igual a {{min}}",
|
||||
"max": "Debe ser menor o igual a {{max}}",
|
||||
"pattern": "Formato inválido"
|
||||
},
|
||||
"dates": {
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"tomorrow": "Mañana",
|
||||
"lastWeek": "Semana Pasada",
|
||||
"lastMonth": "Mes Pasado",
|
||||
"lastYear": "Año Pasado",
|
||||
"custom": "Personalizado",
|
||||
"startDate": "Fecha de Inicio",
|
||||
"endDate": "Fecha de Fin",
|
||||
"dueDate": "Fecha Límite",
|
||||
"publishedDate": "Fecha de Publicación",
|
||||
"createdDate": "Fecha de Creación",
|
||||
"updatedDate": "Fecha de Actualización"
|
||||
},
|
||||
"accessibility": {
|
||||
"skipToContent": "Saltar al contenido",
|
||||
"increaseContrast": "Aumentar Contraste",
|
||||
"screenReader": "Lector de Pantalla"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user