init project

This commit is contained in:
Michael Czechowski
2025-05-13 18:10:40 +02:00
commit c46d6efd6b
22 changed files with 3255 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.idea
.code
node_modules
dist

1450
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "code-crispies",
"version": "1.0.0",
"description": "An interactive platform for learning CSS through practical challenges",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
},
"keywords": [
"css",
"html",
"learning",
"interactive",
"education"
],
"author": "Michael Czechowski <mail@dailysh.it>",
"license": "Copyright Michael Czechowski 2025",
"devDependencies": {
"vite": "^6.3.5",
"vitest": "^3.1.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/site.webmanifest Normal file
View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

49
scaffold.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Define base directory
BASE_DIR="."
# Create directories
mkdir -p "$BASE_DIR/src/styles"
mkdir -p "$BASE_DIR/src/js"
mkdir -p "$BASE_DIR/src/lessons/configs"
# Create files with comments
touch "$BASE_DIR/src/index.html"
echo "<!-- Main entry HTML file -->" > "$BASE_DIR/src/index.html"
touch "$BASE_DIR/src/styles/main.css"
echo "/* Global styles */" > "$BASE_DIR/src/styles/main.css"
touch "$BASE_DIR/src/js/app.js"
echo "// Main application logic" > "$BASE_DIR/src/js/app.js"
touch "$BASE_DIR/src/js/LessonEngine.js"
echo "// Core lesson processing engine" > "$BASE_DIR/src/js/LessonEngine.js"
touch "$BASE_DIR/src/js/renderer.js"
echo "// Handles UI rendering" > "$BASE_DIR/src/js/renderer.js"
touch "$BASE_DIR/src/js/validator.js"
echo "// Validates user code submissions" > "$BASE_DIR/src/js/validator.js"
touch "$BASE_DIR/src/lessons/lesson-config.js"
echo "// Loads and parses lesson configs" > "$BASE_DIR/src/lessons/lesson-config.js"
touch "$BASE_DIR/src/lessons/configs/flexbox.json"
echo "{ /* Flexbox lesson config */ }" > "$BASE_DIR/src/lessons/configs/flexbox.json"
touch "$BASE_DIR/src/lessons/configs/grid.json"
echo "{ /* Grid lesson config */ }" > "$BASE_DIR/src/lessons/configs/grid.json"
touch "$BASE_DIR/src/lessons/configs/basics.json"
echo "{ /* Basics lesson config */ }" > "$BASE_DIR/src/lessons/configs/basics.json"
touch "$BASE_DIR/package.json"
echo "{\n \"name\": \"css-learning-platform\",\n \"version\": \"1.0.0\"\n}" > "$BASE_DIR/package.json"
touch "$BASE_DIR/vite.config.js"
echo "// Vite config file" > "$BASE_DIR/vite.config.js"
echo "Project scaffolded at: $BASE_DIR"

89
src/index.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Crispies - Learn CSS Interactively</title>
<link rel="stylesheet" href="./styles/main.css">
</head>
<body>
<div class="app-container">
<header class="header">
<div class="logo">
<h1>Code Crispies</h1>
</div>
<nav class="main-nav">
<ul>
<li><button id="module-selector-btn" class="btn">Modules</button></li>
<li><button id="reset-btn" class="btn">Reset Progress</button></li>
<li><button id="help-btn" class="btn">Help</button></li>
</ul>
</nav>
</header>
<main class="main-content">
<div class="sidebar">
<div class="module-list">
<!-- Module list will be populated here -->
</div>
<div class="lesson-progress">
<!-- Lesson progress will be shown here -->
</div>
</div>
<div class="content-area">
<div class="lesson-container">
<h2 id="lesson-title">Loading...</h2>
<div class="lesson-description" id="lesson-description">
Please select a lesson to begin.
</div>
<div class="challenge-container">
<div class="preview-area" id="preview-area">
<!-- Preview of the challenge will be shown here -->
</div>
<div class="editor-container">
<div class="task-instruction" id="task-instruction">
<!-- Task instructions will be shown here -->
</div>
<div class="code-editor">
<div class="editor-header">
<span>CSS Editor</span>
<button id="run-btn" class="btn btn-primary">Run</button>
</div>
<div class="editor-content">
<pre><code id="editor-prefix"></code></pre>
<textarea id="code-input" class="code-input" spellcheck="false"></textarea>
<pre><code id="editor-suffix"></code></pre>
</div>
</div>
</div>
</div>
<div class="controls">
<button id="prev-btn" class="btn">Previous</button>
<div class="level-indicator" id="level-indicator">Level 0/0</div>
<button id="next-btn" class="btn btn-primary">Next</button>
</div>
</div>
</div>
</main>
<div id="modal-container" class="modal-container hidden">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Modal Title</h3>
<button id="modal-close" class="modal-close">&times;</button>
</div>
<div class="modal-content" id="modal-content">
<!-- Modal content will be populated here -->
</div>
</div>
</div>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>

230
src/js/LessonEngine.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* LessonEngine - Core class for managing lessons and applying/testing user code
* This file is the implementation of the LessonEngine class declaration from app.js
*/
import { validateUserCode } from './validator.js';
import { showFeedback } from './renderer.js';
export class LessonEngine {
constructor() {
this.currentLesson = null;
this.userCode = '';
this.currentModule = null;
this.currentLessonIndex = 0;
}
/**
* Set the current module
* @param {Object} module - The module object from the config
*/
setModule(module) {
this.currentModule = module;
this.currentLessonIndex = 0;
if (module && module.lessons && module.lessons.length > 0) {
this.setLesson(module.lessons[0]);
}
}
/**
* Set the current lesson
* @param {Object} lesson - The lesson object from the config
*/
setLesson(lesson) {
this.currentLesson = lesson;
this.userCode = lesson.initialCode || '';
this.renderPreview();
}
/**
* Set lesson by index within the current module
* @param {number} index - The lesson index
* @returns {boolean} Whether the operation was successful
*/
setLessonByIndex(index) {
if (!this.currentModule || !this.currentModule.lessons) {
return false;
}
if (index < 0 || index >= this.currentModule.lessons.length) {
return false;
}
this.currentLessonIndex = index;
this.setLesson(this.currentModule.lessons[index]);
return true;
}
/**
* Move to the next lesson
* @returns {boolean} Whether the operation was successful
*/
nextLesson() {
return this.setLessonByIndex(this.currentLessonIndex + 1);
}
/**
* Move to the previous lesson
* @returns {boolean} Whether the operation was successful
*/
previousLesson() {
return this.setLessonByIndex(this.currentLessonIndex - 1);
}
/**
* Apply user-written CSS to the preview area
* @param {string} code - User CSS code
*/
applyUserCode(code) {
if (!this.currentLesson) return;
this.userCode = code;
this.renderPreview();
}
/**
* Render the preview for the current lesson
*/
renderPreview() {
if (!this.currentLesson) return;
const { previewHTML, previewBaseCSS, previewContainer, sandboxCSS } = this.currentLesson;
// Create an iframe for isolated preview rendering
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.title = 'Preview';
// Get the preview container
const container = document.getElementById(previewContainer || 'preview-area');
// Clear the container and add the iframe
container.innerHTML = '';
container.appendChild(iframe);
// Create the complete CSS by combining base CSS with user code and sandbox CSS
const combinedCSS = `
/* Base CSS */
${previewBaseCSS || ''}
/* User Code */
${this.userCode || ''}
/* Sandbox CSS (for visualizing the exercise) */
${sandboxCSS || ''}
`;
// Write the content to the iframe
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>${combinedCSS}</style>
</head>
<body>
${previewHTML || '<div>No preview available</div>'}
</body>
</html>
`);
iframeDoc.close();
}
/**
* Validate user code against the current lesson's requirements
* @returns {Object} Validation result
*/
validateCode() {
if (!this.currentLesson) {
return { isValid: false, message: 'No active lesson to validate against.' };
}
const result = validateUserCode(this.userCode, this.currentLesson);
// Display feedback to the user
showFeedback(result.isValid, result.message);
return result;
}
/**
* Get the current state of the lesson
* @returns {Object} The current lesson state
*/
getCurrentState() {
return {
module: this.currentModule,
lesson: this.currentLesson,
lessonIndex: this.currentLessonIndex,
userCode: this.userCode,
totalLessons: this.currentModule ? this.currentModule.lessons.length : 0
};
}
/**
* Save progress to localStorage
*/
saveProgress() {
if (!this.currentModule || !this.currentLesson) return;
const progressData = {
moduleId: this.currentModule.id,
lessonIndex: this.currentLessonIndex,
userCode: this.userCode,
timestamp: new Date().toISOString()
};
localStorage.setItem('cssQuest_progress', JSON.stringify(progressData));
}
/**
* Load progress from localStorage
* @param {Array} modules - Available modules
* @returns {Object|null} Loaded progress data or null if not found
*/
loadProgress(modules) {
const savedProgress = localStorage.getItem('cssQuest_progress');
if (!savedProgress) return null;
try {
const progressData = JSON.parse(savedProgress);
// Find the module
const module = modules.find(m => m.id === progressData.moduleId);
if (!module) return null;
this.setModule(module);
this.setLessonByIndex(progressData.lessonIndex);
// Restore user code if available
if (progressData.userCode) {
this.userCode = progressData.userCode;
this.renderPreview();
}
return progressData;
} catch (e) {
console.error('Error loading progress:', e);
return null;
}
}
/**
* Reset the current state
*/
reset() {
if (this.currentLesson) {
this.userCode = this.currentLesson.initialCode || '';
this.renderPreview();
}
}
/**
* Clear all saved progress
*/
clearProgress() {
localStorage.removeItem('cssQuest_progress');
}
}

366
src/js/app.js Normal file
View File

@@ -0,0 +1,366 @@
import { LessonEngine } from './LessonEngine';
import { renderLesson, renderModuleList, renderLevelIndicator, showFeedback } from './renderer';
import { validateUserCode } from './validator';
import { loadModules } from '../lessons/lesson-config';
// Main Application state
const state = {
currentModule: null,
currentLessonIndex: 0,
modules: [],
userProgress: {}, // Format: { moduleId: { completed: [0, 2, 3], current: 4 } }
};
// DOM elements
const elements = {
moduleList: document.querySelector('.module-list'),
lessonTitle: document.getElementById('lesson-title'),
lessonDescription: document.getElementById('lesson-description'),
taskInstruction: document.getElementById('task-instruction'),
previewArea: document.getElementById('preview-area'),
editorPrefix: document.getElementById('editor-prefix'),
codeInput: document.getElementById('code-input'),
editorSuffix: document.getElementById('editor-suffix'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
runBtn: document.getElementById('run-btn'),
levelIndicator: document.getElementById('level-indicator'),
modalContainer: document.getElementById('modal-container'),
modalTitle: document.getElementById('modal-title'),
modalContent: document.getElementById('modal-content'),
modalClose: document.getElementById('modal-close'),
moduleSelectorBtn: document.getElementById('module-selector-btn'),
resetBtn: document.getElementById('reset-btn'),
helpBtn: document.getElementById('help-btn'),
};
// Initialize the lesson engine
const lessonEngine = new LessonEngine();
// Load user progress from localStorage
function loadUserProgress() {
const savedProgress = localStorage.getItem('codeCrispiesProgress');
if (savedProgress) {
state.userProgress = JSON.parse(savedProgress);
}
}
// Save user progress to localStorage
function saveUserProgress() {
localStorage.setItem('codeCrispiesProgress', JSON.stringify(state.userProgress));
}
// Initialize the module list
async function initializeModules() {
try {
state.modules = await loadModules();
renderModuleList(elements.moduleList, state.modules, selectModule);
// Select the first module or the last one user was on
const lastModuleId = localStorage.getItem('lastModuleId');
if (lastModuleId && state.modules.find(m => m.id === lastModuleId)) {
selectModule(lastModuleId);
} else if (state.modules.length > 0) {
selectModule(state.modules[0].id);
}
} catch (error) {
console.error('Failed to load modules:', error);
elements.lessonDescription.textContent = 'Failed to load modules. Please refresh the page.';
}
}
// Select a module
function selectModule(moduleId) {
const selectedModule = state.modules.find(module => module.id === moduleId);
if (!selectedModule) return;
state.currentModule = selectedModule;
// Update module list UI
const moduleItems = elements.moduleList.querySelectorAll('.module-list-item');
moduleItems.forEach(item => {
item.classList.remove('active');
if (item.dataset.moduleId === moduleId) {
item.classList.add('active');
}
});
// Load user progress for this module
if (!state.userProgress[moduleId]) {
state.userProgress[moduleId] = { completed: [], current: 0 };
}
state.currentLessonIndex = state.userProgress[moduleId].current || 0;
loadCurrentLesson();
// Save the last selected module
localStorage.setItem('lastModuleId', moduleId);
}
// Load the current lesson
function loadCurrentLesson() {
if (!state.currentModule || !state.currentModule.lessons) {
return;
}
// Make sure lesson index is in bounds
if (state.currentLessonIndex >= state.currentModule.lessons.length) {
state.currentLessonIndex = state.currentModule.lessons.length - 1;
} else if (state.currentLessonIndex < 0) {
state.currentLessonIndex = 0;
}
const lesson = state.currentModule.lessons[state.currentLessonIndex];
lessonEngine.setLesson(lesson);
// Update UI
renderLesson(
elements.lessonTitle,
elements.lessonDescription,
elements.taskInstruction,
elements.previewArea,
elements.editorPrefix,
elements.codeInput,
elements.editorSuffix,
lesson
);
// Update level indicator
renderLevelIndicator(
elements.levelIndicator,
state.currentLessonIndex + 1,
state.currentModule.lessons.length
);
// Update navigation buttons
updateNavigationButtons();
// Save current progress
state.userProgress[state.currentModule.id].current = state.currentLessonIndex;
saveUserProgress();
}
// Update navigation buttons state
function updateNavigationButtons() {
elements.prevBtn.disabled = state.currentLessonIndex === 0;
elements.nextBtn.disabled = !state.currentModule ||
state.currentLessonIndex === state.currentModule.lessons.length - 1;
// Style changes for disabled buttons
if (elements.prevBtn.disabled) {
elements.prevBtn.classList.add('btn-disabled');
} else {
elements.prevBtn.classList.remove('btn-disabled');
}
if (elements.nextBtn.disabled) {
elements.nextBtn.classList.add('btn-disabled');
} else {
elements.nextBtn.classList.remove('btn-disabled');
}
}
// Go to the next lesson
function nextLesson() {
if (!state.currentModule) return;
if (state.currentLessonIndex < state.currentModule.lessons.length - 1) {
state.currentLessonIndex++;
loadCurrentLesson();
}
}
// Go to the previous lesson
function prevLesson() {
if (state.currentLessonIndex > 0) {
state.currentLessonIndex--;
loadCurrentLesson();
}
}
// Run the user code
function runCode() {
const userCode = elements.codeInput.value;
const lesson = state.currentModule.lessons[state.currentLessonIndex];
const validationResult = validateUserCode(userCode, lesson);
if (validationResult.isValid) {
// Mark lesson as completed
const moduleProgress = state.userProgress[state.currentModule.id];
if (!moduleProgress.completed.includes(state.currentLessonIndex)) {
moduleProgress.completed.push(state.currentLessonIndex);
saveUserProgress();
}
// Show success feedback
showFeedback(true, validationResult.message || 'Great job! Your code works correctly.');
// Apply the code to see the result
lessonEngine.applyUserCode(userCode);
// Enable the next button if not already on the last lesson
if (state.currentLessonIndex < state.currentModule.lessons.length - 1) {
elements.nextBtn.disabled = false;
elements.nextBtn.classList.remove('btn-disabled');
}
} else {
// Show error feedback
showFeedback(false, validationResult.message || 'Your code doesn\'t seem to be correct. Try again!');
}
}
// Show the module selector modal
function showModuleSelector() {
debugger;
elements.modalTitle.textContent = 'Select a Module';
// Create module buttons
const moduleButtons = state.modules.map(module => {
const button = document.createElement('button');
button.classList.add('btn', 'module-button');
button.style.display = 'block';
button.style.width = '100%';
button.style.marginBottom = '10px';
button.style.padding = '15px';
button.style.textAlign = 'left';
// Add completion status
const progress = state.userProgress[module.id];
const completedCount = progress ? progress.completed.length : 0;
debugger;
const totalLessons = module.lessons.length;
const percentComplete = Math.round((completedCount / totalLessons) * 100);
button.innerHTML = `
<strong>${module.title}</strong>
<div style="margin-top: 5px; font-size: 0.8rem; color: var(--light-text);">
${module.description}
</div>
<div style="margin-top: 8px; height: 6px; background-color: #f0f0f0; border-radius: 3px;">
<div style="height: 100%; width: ${percentComplete}%; background-color: var(--primary-color); border-radius: 3px;"></div>
</div>
<div style="margin-top: 5px; font-size: 0.8rem; text-align: right;">
${completedCount}/${totalLessons} lessons completed
</div>
`;
button.addEventListener('click', () => {
selectModule(module.id);
closeModal();
});
return button;
});
// Clear and update modal content
elements.modalContent.innerHTML = '';
moduleButtons.forEach(button => {
elements.modalContent.appendChild(button);
});
// Show the modal
elements.modalContainer.classList.remove('hidden');
}
// Show help modal
function showHelp() {
elements.modalTitle.textContent = 'Help';
elements.modalContent.innerHTML = `
<h3>How to Use Code Crispies</h3>
<p>Code Crispies is an interactive platform for learning CSS through practical exercises.</p>
<h4>Getting Started</h4>
<p>Select a module from the sidebar to start learning. Each module contains a series of lessons focused on specific CSS concepts.</p>
<h4>Completing Lessons</h4>
<p>For each lesson:</p>
<ol>
<li>Read the instructions and objective</li>
<li>Write your CSS code in the editor</li>
<li>Click "Run" to test your solution</li>
<li>If correct, you can proceed to the next lesson</li>
</ol>
<h4>Controls</h4>
<ul>
<li><strong>Run</strong> - Test your CSS code</li>
<li><strong>Previous/Next</strong> - Navigate between lessons</li>
<li><strong>Modules</strong> - Select a different learning module</li>
<li><strong>Reset Progress</strong> - Clear all your saved progress</li>
</ul>
<h4>Tips</h4>
<ul>
<li>Use the preview area to see how your CSS affects the elements</li>
<li>Your progress is automatically saved in your browser storage</li>
<li>You can revisit completed lessons at any time</li>
</ul>
`;
elements.modalContainer.classList.remove('hidden');
}
// Reset user progress
function resetProgress() {
elements.modalTitle.textContent = 'Reset Progress';
elements.modalContent.innerHTML = `
<p>Are you sure you want to reset all your progress? This cannot be undone.</p>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
<button id="cancel-reset" class="btn">Cancel</button>
<button id="confirm-reset" class="btn btn-primary">Reset Progress</button>
</div>
`;
document.getElementById('cancel-reset').addEventListener('click', closeModal);
document.getElementById('confirm-reset').addEventListener('click', () => {
localStorage.removeItem('codeCrispiesProgress');
localStorage.removeItem('lastModuleId');
state.userProgress = {};
closeModal();
// Reload the current module
if (state.currentModule) {
const currentModuleId = state.currentModule.id;
selectModule(currentModuleId);
} else if (state.modules.length > 0) {
selectModule(state.modules[0].id);
}
});
elements.modalContainer.classList.remove('hidden');
}
// Close the modal
function closeModal() {
elements.modalContainer.classList.add('hidden');
}
// Initialize the application
function init() {
loadUserProgress();
initializeModules();
// Event listeners
elements.prevBtn.addEventListener('click', prevLesson);
elements.nextBtn.addEventListener('click', nextLesson);
elements.runBtn.addEventListener('click', runCode);
elements.modalClose.addEventListener('click', closeModal);
elements.moduleSelectorBtn.addEventListener('click', showModuleSelector);
elements.resetBtn.addEventListener('click', resetProgress);
elements.helpBtn.addEventListener('click', showHelp);
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl+Enter to run code
if (e.ctrlKey && e.key === 'Enter') {
runCode();
e.preventDefault();
}
});
}
// Start the application
init();

119
src/js/renderer.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Renderer - Handles UI updates for the CSS learning platform
*/
// Feedback elements cache
let feedbackElement = null;
/**
* Render the module list in the sidebar
* @param {HTMLElement} container - The container element for the module list
* @param {Array} modules - The list of modules
* @param {Function} onSelectModule - Callback when a module is selected
*/
export function renderModuleList(container, modules, onSelectModule) {
// Clear the container
container.innerHTML = '<h3>Modules</h3>';
// Create list items for each module
modules.forEach(module => {
const moduleItem = document.createElement('div');
moduleItem.classList.add('module-list-item');
moduleItem.dataset.moduleId = module.id;
moduleItem.textContent = module.title;
moduleItem.addEventListener('click', () => {
onSelectModule(module.id);
});
container.appendChild(moduleItem);
});
}
/**
* Render a lesson in the UI
* @param {HTMLElement} titleEl - The lesson title element
* @param {HTMLElement} descriptionEl - The lesson description element
* @param {HTMLElement} taskEl - The task instruction element
* @param {HTMLElement} previewEl - The preview area element
* @param {HTMLElement} prefixEl - The code editor prefix element
* @param {HTMLElement} inputEl - The code input element
* @param {HTMLElement} suffixEl - The code editor suffix element
* @param {Object} lesson - The lesson object
*/
export function renderLesson(
titleEl,
descriptionEl,
taskEl,
previewEl,
prefixEl,
inputEl,
suffixEl,
lesson
) {
// Set lesson title and description
titleEl.textContent = lesson.title || 'Untitled Lesson';
descriptionEl.innerHTML = lesson.description || '';
// Set task instructions
taskEl.innerHTML = lesson.task || '';
// Set code editor contents
prefixEl.textContent = lesson.codePrefix || '';
inputEl.value = lesson.initialCode || '';
suffixEl.textContent = lesson.codeSuffix || '';
// Clear any existing feedback
clearFeedback();
// Initial preview render with empty user code
// The LessonEngine will handle this when it's first set
}
/**
* Update the level indicator
* @param {HTMLElement} element - The level indicator element
* @param {number} current - The current level number
* @param {number} total - The total number of levels
*/
export function renderLevelIndicator(element, current, total) {
element.textContent = `Lesson ${current} of ${total}`;
}
/**
* Show feedback for user submissions
* @param {boolean} isSuccess - Whether the submission was successful
* @param {string} message - The feedback message
*/
export function showFeedback(isSuccess, message) {
// Clear any existing feedback
clearFeedback();
// Create feedback element
feedbackElement = document.createElement('div');
feedbackElement.classList.add(isSuccess ? 'feedback-success' : 'feedback-error');
feedbackElement.textContent = message;
// Find where to insert the feedback
const insertAfter = document.querySelector('.code-editor');
if (insertAfter && insertAfter.parentNode) {
insertAfter.parentNode.insertBefore(feedbackElement, insertAfter.nextSibling);
}
// Auto-remove feedback after some time if successful
if (isSuccess) {
setTimeout(() => {
clearFeedback();
}, 5000);
}
}
/**
* Clear any existing feedback
*/
export function clearFeedback() {
if (feedbackElement && feedbackElement.parentNode) {
feedbackElement.parentNode.removeChild(feedbackElement);
}
feedbackElement = null;
}

183
src/js/validator.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* Validator - Functions to validate user CSS code
*/
/**
* Validate user CSS code against the lesson requirements
* @param {string} userCode - User submitted CSS code
* @param {Object} lesson - The current lesson object
* @returns {Object} Validation result with isValid and message properties
*/
export function validateUserCode(userCode, lesson) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: 'No validations specified for this lesson.' };
}
// Get the validations array from the lesson
const validations = lesson.validations;
// Default validation result
let result = {
isValid: true,
message: 'Your code looks good!'
};
// Process each validation rule
for (const validation of validations) {
const { type, value, message, options } = validation;
switch (type) {
case 'contains':
if (!containsValidation(userCode, value, options)) {
return { isValid: false, message: message || `Your code should include "${value}".` };
}
break;
case 'not_contains':
if (containsValidation(userCode, value, options)) {
return { isValid: false, message: message || `Your code should not include "${value}".` };
}
break;
case 'regex':
if (!regexValidation(userCode, value, options)) {
return { isValid: false, message: message || 'Your code does not match the expected pattern.' };
}
break;
case 'property_value':
if (!propertyValueValidation(userCode, value, options)) {
return { isValid: false, message: message || `The "${value.property}" property should be set to "${value.expected}".` };
}
break;
case 'syntax':
const syntaxResult = syntaxValidation(userCode);
if (!syntaxResult.isValid) {
return { isValid: false, message: message || `CSS syntax error: ${syntaxResult.error}` };
}
break;
case 'custom':
if (validation.validator && typeof validation.validator === 'function') {
const customResult = validation.validator(userCode);
if (!customResult.isValid) {
return { isValid: false, message: customResult.message || message || 'Your code does not meet the requirements.' };
}
}
break;
// Add more validation types as needed
default:
console.warn(`Unknown validation type: ${type}`);
}
}
// If we've passed all validations, return success
return result;
}
/**
* Check if code contains a specific string or pattern
* @param {string} code - User CSS code
* @param {string} value - String to check for
* @param {Object} options - Validation options
* @returns {boolean} Whether the validation passes
*/
function containsValidation(code, value, options = {}) {
const { caseSensitive = true, wholeWord = false } = options;
if (!caseSensitive) {
code = code.toLowerCase();
value = value.toLowerCase();
}
if (wholeWord) {
const regex = new RegExp(`\\b${escapeRegExp(value)}\\b`, caseSensitive ? '' : 'i');
return regex.test(code);
}
return code.includes(value);
}
/**
* Check if code matches a regex pattern
* @param {string} code - User CSS code
* @param {string} pattern - Regex pattern to check
* @param {Object} options - Validation options
* @returns {boolean} Whether the validation passes
*/
function regexValidation(code, pattern, options = {}) {
const { caseSensitive = true, multiline = true } = options;
let flags = '';
if (!caseSensitive) flags += 'i';
if (multiline) flags += 'm';
try {
const regex = new RegExp(pattern, flags);
return regex.test(code);
} catch (e) {
console.error('Invalid regex in validation:', e);
return false;
}
}
/**
* Check if a CSS property has the expected value
* @param {string} code - User CSS code
* @param {Object} value - Object with property and expected value
* @param {Object} options - Validation options
* @returns {boolean} Whether the validation passes
*/
function propertyValueValidation(code, value, options = {}) {
const { property, expected } = value;
const { exact = false } = options;
// Create a regex to extract the property value
// This is a simplified version and might not handle all CSS syntax nuances
const propertyRegex = new RegExp(`${escapeRegExp(property)}\\s*:\\s*([^;\\}]+)`, 'i');
const match = code.match(propertyRegex);
if (!match) {
// Property not found
return false;
}
const actualValue = match[1].trim();
if (exact) {
return actualValue === expected;
} else {
// Allow for flexible matching
return actualValue.toLowerCase().includes(expected.toLowerCase());
}
}
/**
* Validate CSS syntax
* @param {string} code - User CSS code
* @returns {Object} Validation result
*/
function syntaxValidation(code) {
try {
// Create a hidden style element to test the CSS
const style = document.createElement('style');
style.textContent = code;
document.head.appendChild(style);
document.head.removeChild(style);
return { isValid: true };
} catch (e) {
return { isValid: false, error: e.message };
}
}
/**
* Escape string for safe use in regex
* @param {string} string - String to escape
* @returns {string} Escaped string
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,296 @@
{
"id": "basics",
"title": "CSS Basics",
"description": "Learn the fundamental concepts of CSS styling",
"difficulty": "beginner",
"lessons": [
{
"id": "basics-1",
"title": "Introduction to Selectors",
"description": "Selectors are patterns used to select HTML elements you want to style. In this lesson, you'll learn how to target elements with CSS.",
"task": "Style the paragraph element by making its text color blue. Use the 'color' property with the value 'blue'.",
"previewHTML": "<p class='target'>This paragraph needs to be blue!</p><p>This paragraph should remain unchanged.</p>",
"previewBaseCSS": "body { font-family: sans-serif; padding: 20px; }",
"sandboxCSS": ".target { outline: 2px dashed #ccc; padding: 10px; }",
"codePrefix": "/* Style the paragraph with class 'target' */\n",
"initialCode": "",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": ".target",
"message": "Make sure to use the '.target' class selector",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "color",
"message": "Use the 'color' property",
"options": {
"caseSensitive": false
}
},
{
"type": "property_value",
"value": {
"property": "color",
"expected": "blue"
},
"message": "Set the color property to 'blue'",
"options": {
"exact": false
}
}
]
},
{
"id": "basics-2",
"title": "Box Model Basics",
"description": "The CSS box model is a fundamental concept that describes how elements are sized and spaced on a page.",
"task": "Add padding of 20px and a 2px solid border with color #333 to the box element.",
"previewHTML": "<div class='box'>This box needs padding and a border!</div>",
"previewBaseCSS": "body { font-family: sans-serif; padding: 20px; } .box { background-color: #f0f0f0; }",
"sandboxCSS": "",
"codePrefix": "/* Add padding and border to the box */\n.box {\n /* Add your code below */\n",
"initialCode": "",
"codeSuffix": "\n}",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "padding",
"message": "Use the 'padding' property",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "border",
"message": "Use the 'border' property",
"options": {
"caseSensitive": false
}
},
{
"type": "property_value",
"value": {
"property": "padding",
"expected": "20px"
},
"message": "Set the padding to '20px'",
"options": {
"exact": false
}
},
{
"type": "regex",
"value": "border:\\s*2px\\s+solid\\s+#333",
"message": "Set the border to '2px solid #333'",
"options": {
"caseSensitive": false
}
}
]
},
{
"id": "basics-3",
"title": "Colors and Backgrounds",
"description": "Learn how to apply background colors and control text coloring in different ways.",
"task": "Style the element with a background color of #e0f7fa and text color of #01579b.",
"previewHTML": "<div class='colorbox'>This element needs colors!</div>",
"previewBaseCSS": "body { font-family: sans-serif; padding: 20px; } .colorbox { padding: 25px; text-align: center; font-weight: bold; font-size: 18px; }",
"sandboxCSS": "",
"codePrefix": "/* Style the colorbox with background and text colors */\n",
"initialCode": "",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": ".colorbox",
"message": "Use the '.colorbox' class selector",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "background-color",
"message": "Use the 'background-color' property",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "color",
"message": "Use the 'color' property",
"options": {
"caseSensitive": false
}
},
{
"type": "property_value",
"value": {
"property": "background-color",
"expected": "#e0f7fa"
},
"message": "Set the background-color to '#e0f7fa'",
"options": {
"exact": true
}
},
{
"type": "property_value",
"value": {
"property": "color",
"expected": "#01579b"
},
"message": "Set the text color to '#01579b'",
"options": {
"exact": true
}
}
]
},
{
"id": "basics-4",
"title": "Typography Basics",
"description": "Learn how to control text appearance with CSS typography properties.",
"task": "Style the heading with font-family 'Georgia, serif', font-size of 24px, and add text-shadow of 1px 1px 2px #aaa.",
"previewHTML": "<h2 class='heading'>Style This Heading Text</h2>",
"previewBaseCSS": "body { font-family: sans-serif; padding: 20px; }",
"sandboxCSS": ".heading { border-bottom: 1px solid #ddd; padding-bottom: 10px; }",
"codePrefix": "/* Style the heading text */\n",
"initialCode": "",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": ".heading",
"message": "Use the '.heading' class selector",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "font-family",
"message": "Use the 'font-family' property",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "font-size",
"message": "Use the 'font-size' property",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "text-shadow",
"message": "Use the 'text-shadow' property",
"options": {
"caseSensitive": false
}
},
{
"type": "regex",
"value": "text-shadow:\\s*1px\\s+1px\\s+2px\\s+#aaa",
"message": "Set the text-shadow to '1px 1px 2px #aaa'",
"options": {
"caseSensitive": false
}
}
]
},
{
"id": "basics-5",
"title": "CSS Units",
"description": "Learn about different CSS units like pixels, percentages, em, rem, and when to use each.",
"task": "Set the width of the box to 80%, the max-width to 600px, and the font-size to 1.2rem.",
"previewHTML": "<div class='units-box'>This box needs sizing with different units.</div>",
"previewBaseCSS": "body { font-family: sans-serif; padding: 20px; } .units-box { background-color: #f5f5f5; padding: 15px; border: 1px solid #ddd; }",
"sandboxCSS": "",
"codePrefix": "/* Style the box using different units */\n",
"initialCode": "",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": ".units-box",
"message": "Use the '.units-box' class selector",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "width",
"message": "Use the 'width' property",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "max-width",
"message": "Use the 'max-width' property",
"options": {
"caseSensitive": false
}
},
{
"type": "contains",
"value": "font-size",
"message": "Use the 'font-size' property",
"options": {
"caseSensitive": false
}
},
{
"type": "property_value",
"value": {
"property": "width",
"expected": "80%"
},
"message": "Set the width to '80%'",
"options": {
"exact": true
}
},
{
"type": "property_value",
"value": {
"property": "max-width",
"expected": "600px"
},
"message": "Set the max-width to '600px'",
"options": {
"exact": true
}
},
{
"type": "property_value",
"value": {
"property": "font-size",
"expected": "1.2rem"
},
"message": "Set the font-size to '1.2rem'",
"options": {
"exact": true
}
}
]
}
]
}

View File

@@ -0,0 +1,4 @@
{
"id": "flexbox",
"title": "Flexbox Layout"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,99 @@
/**
* Lesson Config - Functions for loading lesson configurations
*/
// Import lesson configs
import flexboxConfig from './configs/flexbox.json';
import gridConfig from './configs/grid.json';
import basicsConfig from './configs/basics.json';
// Module store
const moduleStore = [
// flexboxConfig,
// gridConfig,
basicsConfig
];
/**
* Load all available modules
* @returns {Promise<Array>} Promise resolving to array of modules
*/
export async function loadModules() {
// In a real app, we might load these from a server
return moduleStore;
}
/**
* Get a module by its ID
* @param {string} moduleId - The module ID to find
* @returns {Object|null} The module object or null if not found
*/
export function getModuleById(moduleId) {
return moduleStore.find(module => module.id === moduleId) || null;
}
/**
* Load module configs from a URL
* @param {string} url - URL to load the config from
* @returns {Promise<Object>} Promise resolving to the module config
*/
export async function loadModuleFromUrl(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load module: ${response.status} ${response.statusText}`);
}
const moduleConfig = await response.json();
validateModuleConfig(moduleConfig);
return moduleConfig;
} catch (error) {
console.error('Error loading module from URL:', error);
throw error;
}
}
/**
* Validate a module configuration
* @param {Object} config - The module configuration to validate
* @throws {Error} If the configuration is invalid
*/
function validateModuleConfig(config) {
// Required fields
if (!config.id) throw new Error('Module config missing "id"');
if (!config.title) throw new Error('Module config missing "title"');
if (!Array.isArray(config.lessons)) throw new Error('Module config missing "lessons" array');
// Check each lesson
config.lessons.forEach((lesson, index) => {
if (!lesson.title) throw new Error(`Lesson ${index} missing "title"`);
if (!lesson.previewHTML) throw new Error(`Lesson ${index} missing "previewHTML"`);
});
}
/**
* Add a custom module to the store
* @param {Object} moduleConfig - The module configuration to add
* @returns {boolean} Success status
*/
export function addCustomModule(moduleConfig) {
try {
validateModuleConfig(moduleConfig);
// Check if module with same ID already exists
const existingIndex = moduleStore.findIndex(m => m.id === moduleConfig.id);
if (existingIndex >= 0) {
// Replace existing module
moduleStore[existingIndex] = moduleConfig;
} else {
// Add new module
moduleStore.push(moduleConfig);
}
return true;
} catch (error) {
console.error('Error adding custom module:', error);
return false;
}
}

324
src/styles/main.css Normal file
View File

@@ -0,0 +1,324 @@
:root {
--primary-color: #4a6bfd;
--primary-light: #7a93fe;
--primary-dark: #244ae8;
--secondary-color: #ff7e5f;
--text-color: #2c3e50;
--light-text: #777;
--bg-color: #f9f9f9;
--panel-bg: #ffffff;
--border-color: #e0e0e0;
--success-color: #2ecc71;
--error-color: #e74c3c;
--font-main: 'Inter', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-main), serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header Styles */
.header {
background-color: var(--panel-bg);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 100;
}
.logo h1 {
color: var(--primary-color);
font-size: 1.7rem;
font-weight: 700;
}
.main-nav ul {
display: flex;
list-style: none;
gap: 1rem;
}
/* Main Content Layout */
.main-content {
display: flex;
flex: 1;
min-height: calc(100vh - 60px);
}
.sidebar {
width: 240px;
background-color: var(--panel-bg);
border-right: 1px solid var(--border-color);
padding: 1.5rem 1rem;
overflow-y: auto;
height: calc(100vh - 60px);
position: sticky;
top: 60px;
}
.content-area {
flex: 1;
padding: 2rem;
max-width: calc(100% - 240px);
}
/* Module List Styles */
.module-list {
margin-bottom: 2rem;
}
.module-list-item {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.module-list-item:hover {
background-color: rgba(74, 107, 253, 0.05);
}
.module-list-item.active {
background-color: rgba(74, 107, 253, 0.1);
color: var(--primary-color);
font-weight: 600;
}
/* Lesson Container */
.lesson-container {
background-color: var(--panel-bg);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 2rem;
margin-bottom: 2rem;
}
#lesson-title {
margin-bottom: 1rem;
color: var(--primary-dark);
}
.lesson-description {
margin-bottom: 2rem;
color: var(--text-color);
line-height: 1.7;
}
/* Challenge Container */
.challenge-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (min-width: 1024px) {
.challenge-container {
flex-direction: row;
}
.preview-area,
.editor-container {
width: 48%;
}
}
.preview-area {
background-color: #fff;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
overflow: hidden;
min-height: 300px;
display: flex;
justify-content: center;
align-items: center;
}
.editor-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-instruction {
background-color: rgba(74, 107, 253, 0.05);
border-left: 4px solid var(--primary-color);
padding: 1rem;
border-radius: 4px;
}
.code-editor {
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.editor-header {
background-color: #f5f5f5;
padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: var(--light-text);
border-bottom: 1px solid var(--border-color);
}
.editor-content {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
}
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.code-input {
background-color: #1e1e1e;
color: #d4d4d4;
border: none;
width: 100%;
min-height: 100px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
padding: 0.5rem 0;
outline: none;
resize: vertical;
}
/* Controls */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
}
.level-indicator {
font-size: 0.9rem;
color: var(--light-text);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--border-color);
background-color: #fff;
color: var(--text-color);
cursor: pointer;
font-family: var(--font-main);
font-size: 0.9rem;
transition: all 0.2s;
}
.btn:hover {
background-color: #f5f5f5;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
border: 1px solid var(--primary-dark);
}
.btn-primary:hover {
background-color: var(--primary-dark);
}
/* Modal */
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background-color: var(--panel-bg);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-content {
padding: 1.5rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--light-text);
}
.hidden {
display: none;
}
/* Feedback */
.feedback-success {
color: var(--success-color);
font-weight: 500;
margin-top: 1rem;
padding: 0.5rem;
border-radius: 4px;
background-color: rgba(46, 204, 113, 0.1);
border-left: 3px solid var(--success-color);
}
.feedback-error {
color: var(--error-color);
font-weight: 500;
margin-top: 1rem;
padding: 0.5rem;
border-radius: 4px;
background-color: rgba(231, 76, 60, 0.1);
border-left: 3px solid var(--error-color);
}

15
vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: './src',
publicDir: './public',
build: {
outDir: './dist',
emptyOutDir: true,
sourcemap: true
},
server: {
port: 3000,
open: true
}
});