Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions src/components/cms/ContentEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React from 'react';
import React, { useState, useEffect } from 'react';
import { RichContentEditor } from '../editor/RichContentEditor';
import { useCMS } from '@/hooks/useCMS';

Expand All @@ -15,40 +15,87 @@ interface ContentEditorProps {
*/
export const ContentEditor: React.FC<ContentEditorProps> = ({ moduleId, lessonId }) => {
const { course, updateLessonContent } = useCMS();
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');

// Find the lesson in the course structure
const currentModule = course.modules.find((m) => m.id === moduleId);
const lesson = currentModule?.lessons.find((l) => l.id === lessonId);

const handleUpdate = (content: string) => {
setSaveStatus('saving');
updateLessonContent(moduleId, lessonId, content);

// Simulate save completion
setTimeout(() => {
setLastSaved(new Date());
setSaveStatus('saved');

// Reset to idle after 2 seconds
setTimeout(() => {
setSaveStatus('idle');
}, 2000);
}, 500);
};

if (!lesson) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="flex items-center justify-center h-full text-gray-500" role="status">
Select a lesson to start editing.
</div>
);
}

const getStatusText = () => {
switch (saveStatus) {
case 'saving':
return 'Saving...';
case 'saved':
return 'Saved';
case 'error':
return 'Save failed';
default:
return lastSaved ? `Last saved: ${lastSaved.toLocaleTimeString()}` : 'Ready';
}
};

const getStatusColor = () => {
switch (saveStatus) {
case 'saving':
return 'bg-yellow-500';
case 'saved':
return 'bg-green-500';
case 'error':
return 'bg-red-500';
default:
return 'bg-gray-400';
}
};

return (
<div className="flex flex-col h-full space-y-4">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<header className="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
Editing: <span className="text-blue-500">{lesson.title}</span>
</h2>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-400">
Last saved: {new Date().toLocaleTimeString()}
<span
aria-live="polite"
aria-atomic="true"
className="text-xs text-gray-400"
>
{getStatusText()}
</span>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<div
className={`w-2 h-2 rounded-full ${getStatusColor()} ${saveStatus === 'saving' ? 'animate-pulse' : ''}`}
aria-hidden="true"
/>
</div>
</div>
</header>

<div className="flex-1 overflow-hidden">
<main className="flex-1 overflow-hidden">
<RichContentEditor initialContent={lesson.content} onUpdate={handleUpdate} />
</div>
</main>
</div>
);
};
22 changes: 15 additions & 7 deletions src/components/editor/CollaborativeEditingTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,31 @@ const COLLABORATORS = [
];

export const CollaborativeEditingTools: React.FC = () => {
const collaboratorNames = COLLABORATORS.map(c => c.name).join(', ');

return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2" role="list" aria-label="Active collaborators">
{COLLABORATORS.map((user) => (
<div className="flex items-center gap-2" role="group" aria-label="Collaborative editing">
<div
className="flex -space-x-2"
role="list"
aria-label={`Active collaborators: ${collaboratorNames}`}
>
{COLLABORATORS.map((user, index) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={user.id}
role="listitem"
src={user.avatar}
alt={`${user.name} is editing`}
title={`${user.name} is editing`}
className="w-8 h-8 rounded-full border-2 border-white dark:border-gray-800"
alt={`${user.name}, collaborator ${index + 1} of ${COLLABORATORS.length}`}
title={user.name}
className="w-8 h-8 rounded-full border-2 border-white dark:border-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
tabIndex={0}
/>
))}
</div>
<span
aria-label={`${COLLABORATORS.length} active collaborators`}
role="status"
aria-live="polite"
className="text-xs text-green-500 font-medium px-2 py-1 bg-green-100 dark:bg-green-900 rounded-full"
>
{COLLABORATORS.length} active
Expand Down
55 changes: 29 additions & 26 deletions src/components/editor/ContentTemplateLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,44 +13,47 @@ export const ContentTemplateLibrary: React.FC<ContentTemplateLibraryProps> = ({
const getIcon = (id: string) => {
switch (id) {
case 'lesson-header':
return <BookOpen className="w-4 h-4" />;
return <BookOpen className="w-4 h-4" aria-hidden="true" />;
case 'code-block':
return <Layout className="w-4 h-4" />;
return <Layout className="w-4 h-4" aria-hidden="true" />;
case 'quiz-block':
return <ListChecks className="w-4 h-4" />;
return <ListChecks className="w-4 h-4" aria-hidden="true" />;
case 'video-placeholder':
return <FileVideo className="w-4 h-4" />;
return <FileVideo className="w-4 h-4" aria-hidden="true" />;
default:
return <Layout className="w-4 h-4" />;
return <Layout className="w-4 h-4" aria-hidden="true" />;
}
};

return (
<aside
aria-label="Content templates"
aria-label="Content templates sidebar"
className="p-4 bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 h-full w-64 hidden lg:block"
>
<h3 className="font-semibold text-sm text-gray-500 uppercase mb-4 tracking-wider">
<h2 className="font-semibold text-sm text-gray-500 uppercase mb-4 tracking-wider">
Templates
</h3>
<div className="space-y-2">
{TEMPLATES.map((template) => (
<button
key={template.id}
onClick={() => insertTemplate(editor, template.id)}
aria-label={`Insert ${template.name} template: ${template.description}`}
className="w-full flex items-center gap-3 p-3 text-left rounded-lg bg-white dark:bg-gray-800 hover:bg-blue-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 transition-colors group"
>
<div className="text-gray-500 group-hover:text-blue-500" aria-hidden="true">
{getIcon(template.id)}
</div>
<div>
<div className="font-medium text-sm">{template.name}</div>
<div className="text-xs text-gray-400 truncate w-32">{template.description}</div>
</div>
</button>
))}
</div>
</h2>
<nav aria-label="Template selection">
<ul className="space-y-2" role="list">
{TEMPLATES.map((template) => (
<li key={template.id}>
<button
onClick={() => insertTemplate(editor, template.id)}
aria-label={`Insert ${template.name} template: ${template.description}`}
className="w-full flex items-center gap-3 p-3 text-left rounded-lg bg-white dark:bg-gray-800 hover:bg-blue-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 transition-colors group focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div className="text-gray-500 group-hover:text-blue-500">
{getIcon(template.id)}
</div>
<div>
<div className="font-medium text-sm">{template.name}</div>
<div className="text-xs text-gray-400 truncate w-32">{template.description}</div>
</div>
</button>
</li>
))}
</ul>
</nav>
</aside>
);
};
71 changes: 68 additions & 3 deletions src/components/editor/MediaEmbedder.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Image as ImageIcon, Youtube as YoutubeIcon } from 'lucide-react';
import { sanitizeUrl } from '@/utils/sanitize';

Expand All @@ -14,6 +14,63 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
const [urlError, setUrlError] = useState('');
const dialogTitleId = 'media-embedder-title';
const errorId = 'media-embedder-error';
const dialogRef = useRef<HTMLDivElement>(null);
const triggerButtonRef = useRef<HTMLButtonElement>(null);

// Focus trap implementation
useEffect(() => {
if (!isOpen) return;

const dialog = dialogRef.current;
if (!dialog) return;

const focusableElements = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

// Focus first element when dialog opens
firstElement?.focus();

const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;

if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};

const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
triggerButtonRef.current?.focus();
}
};

document.addEventListener('keydown', handleTab);
document.addEventListener('keydown', handleEscape);

return () => {
document.removeEventListener('keydown', handleTab);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);

// Return focus to trigger button when dialog closes
useEffect(() => {
if (!isOpen && triggerButtonRef.current) {
triggerButtonRef.current.focus();
}
}, [isOpen]);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -32,10 +89,17 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
setIsOpen(false);
};

const handleCancel = () => {
setUrl('');
setUrlError('');
setIsOpen(false);
};

if (!isOpen) {
return (
<div className="flex gap-2">
<button
ref={triggerButtonRef}
onClick={() => {
setType('image');
setIsOpen(true);
Expand Down Expand Up @@ -68,7 +132,7 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
aria-labelledby={dialogTitleId}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg w-96">
<div ref={dialogRef} className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg w-96">
<h3 id={dialogTitleId} className="text-lg font-bold mb-4">
Add {type === 'image' ? 'Image' : 'YouTube Video'}
</h3>
Expand All @@ -87,6 +151,7 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
placeholder={`Enter ${type} URL...`}
className="w-full p-2 border rounded mb-1 dark:bg-gray-700 dark:border-gray-600"
aria-describedby={errorId}
aria-invalid={!!urlError}
required
/>
<p
Expand All @@ -100,7 +165,7 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsOpen(false)}
onClick={handleCancel}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
Expand Down
Loading
Loading