Compare commits
64 Commits
feature/se
...
feat/impl-
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b9b99937 | |||
| 7ab095718b | |||
| 5a243f332a | |||
| 739470e045 | |||
| 07aafa0d89 | |||
| eb82eed826 | |||
| 82f6e46d3c | |||
| 847b261f16 | |||
| 2ce88f9cb7 | |||
| a8ef3d3c5c | |||
| 0f5ac81fe8 | |||
| cf0d2cba51 | |||
| d5bd23615f | |||
| fcc6748aae | |||
| 5c16a8a767 | |||
| 17b3d5380d | |||
| f9311d83f7 | |||
| f4ce61ba64 | |||
| 813d669302 | |||
| 9328399dcb | |||
| 857ae9c3ef | |||
| c91e8d6f32 | |||
|
|
9821e014c5 | ||
| e0cee41482 | |||
| 11877e8e7a | |||
| d78f0ac0b4 | |||
| 0b22263a68 | |||
| baaf05dda4 | |||
| 2aa35cea2d | |||
| f0e2072ac7 | |||
| 072859459f | |||
| 062659fa30 | |||
| a7dcb3ec6f | |||
| 28d41344d1 | |||
| fb5fbe4107 | |||
| 7ecc115c55 | |||
| d802172e5b | |||
| 73a0c59722 | |||
| 630a0a6a21 | |||
| be9c753a0e | |||
| a7e765cb80 | |||
| d2fbe0e085 | |||
| b051974957 | |||
| 68407fe12b | |||
| ea57ce6d28 | |||
| 0fb352c027 | |||
| 9f9dc73b11 | |||
| 0748b23d4c | |||
| 1b3c2b42dc | |||
| efbadbfb76 | |||
| 547840c3fd | |||
| 55379c14f0 | |||
| c59736c0e2 | |||
| c0e1dab0d9 | |||
| 469f6a81a5 | |||
| cb87adb249 | |||
| 96b71079d8 | |||
| 8513189efe | |||
| e65fdb0abc | |||
| 30635a9e69 | |||
| d408c49e45 | |||
| 817dc09a58 | |||
| 4c56342cb7 | |||
| 0a03d51e63 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,8 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Claude Code local settings (user-specific)
|
||||
.claude/settings.local.json
|
||||
297
docs/ROADMAP.md
Normal file
297
docs/ROADMAP.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# Code Crispies Roadmap
|
||||
|
||||
## Current State (Updated)
|
||||
|
||||
**Total Active Lessons:** 101 (excluding welcome, goodbye, playground)
|
||||
**Target:** 100 lessons for milestone system ✅ ACHIEVED
|
||||
|
||||
### Current Module Breakdown
|
||||
|
||||
| Module | Lessons | Category | Status |
|
||||
|--------|---------|----------|--------|
|
||||
| Basic Selectors | 10 | CSS | ✅ |
|
||||
| Colors | 4 | CSS | ✅ |
|
||||
| **Gradients** | 3 | CSS | ✅ NEW |
|
||||
| Typography | 6 | CSS | ✅ +2 |
|
||||
| Box Model | 8 | CSS | ✅ |
|
||||
| Flexbox | 6 | CSS | ✅ |
|
||||
| Grid | 6 | CSS | ✅ |
|
||||
| **Positioning** | 4 | CSS | ✅ NEW |
|
||||
| Units & Variables | 4 | CSS | ✅ |
|
||||
| Responsive | 4 | CSS | ✅ |
|
||||
| Transitions & Animations | 4 | CSS | ✅ |
|
||||
| **Filters** | 4 | CSS | ✅ NEW |
|
||||
| **Pseudo-elements** | 4 | CSS | ✅ NEW |
|
||||
| HTML Elements | 2 | HTML | ✅ |
|
||||
| **Semantic HTML** | 3 | HTML | ✅ NEW |
|
||||
| Figure | 3 | HTML | ✅ |
|
||||
| SVG | 3 | HTML | ✅ |
|
||||
| Details/Summary | 3 | HTML | ✅ |
|
||||
| Dialog | 2 | HTML | ✅ |
|
||||
| Progress/Meter | 3 | HTML | ✅ |
|
||||
| Forms Basic | 3 | HTML | ✅ |
|
||||
| Forms Validation | 1 | HTML | ✅ |
|
||||
| Fieldset | 3 | HTML | ✅ |
|
||||
| Datalist | 2 | HTML | ✅ |
|
||||
| Tables | 3 | HTML | ✅ +2 |
|
||||
| **Total** | **101** | | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Milestone Progress System ✅ COMPLETED
|
||||
|
||||
### Design
|
||||
|
||||
Replace percentage-based progress with milestone markers:
|
||||
|
||||
```
|
||||
[1] [5] [10] [20] [30] [50] [75] [100]
|
||||
● ● ◐ ○ ○ ○ ○ ○
|
||||
```
|
||||
|
||||
**Milestones:**
|
||||
- 1 lesson - First Step
|
||||
- 5 lessons - Getting Started
|
||||
- 10 lessons - Rookie
|
||||
- 20 lessons - Learner
|
||||
- 30 lessons - Intermediate
|
||||
- 50 lessons - Halfway Hero
|
||||
- 75 lessons - Advanced
|
||||
- 100 lessons - Master
|
||||
|
||||
### Implementation ✅
|
||||
|
||||
1. **Update `LessonEngine.getProgressStats()`** ✅
|
||||
- Added `currentMilestone` and `nextMilestone` fields
|
||||
- Added `milestonesReached: number[]`
|
||||
- Added `progressToNext` percentage
|
||||
|
||||
2. **Update Progress UI** ✅
|
||||
- Added milestone dots with visual states (reached, current, next)
|
||||
- Animate milestone completion
|
||||
- Show current milestone badge
|
||||
|
||||
3. **Add Milestone Celebration**
|
||||
- Confetti/animation on reaching milestones
|
||||
- Achievement unlocks in sidebar
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: New Lessons (34 needed to reach 100)
|
||||
|
||||
### Priority 1: Expand Existing Modules (+15 lessons)
|
||||
|
||||
#### CSS Colors (+3)
|
||||
- Gradients (linear-gradient)
|
||||
- Color functions (hsl, rgb)
|
||||
- Opacity and transparency
|
||||
|
||||
#### Typography (+3)
|
||||
- Web fonts (@font-face)
|
||||
- Text shadows
|
||||
- Letter/word spacing
|
||||
|
||||
#### Responsive (+3)
|
||||
- Container queries
|
||||
- Aspect ratio
|
||||
- Clamp() for fluid typography
|
||||
|
||||
#### Transitions & Animations (+3)
|
||||
- Keyframe animations
|
||||
- Animation timing functions
|
||||
- Transform origin
|
||||
|
||||
#### Tables (+3)
|
||||
- Table styling (borders, spacing)
|
||||
- Responsive tables
|
||||
- Table accessibility
|
||||
|
||||
### Priority 2: New CSS Modules (+12 lessons)
|
||||
|
||||
#### Filters & Effects (4 lessons)
|
||||
- CSS filters (blur, brightness, contrast)
|
||||
- Backdrop filters
|
||||
- Mix-blend-mode
|
||||
- Box shadows advanced
|
||||
|
||||
#### Positioning (4 lessons)
|
||||
- Relative positioning
|
||||
- Absolute positioning
|
||||
- Fixed/sticky positioning
|
||||
- Z-index stacking
|
||||
|
||||
#### Pseudo-elements (4 lessons)
|
||||
- ::before and ::after
|
||||
- ::first-letter and ::first-line
|
||||
- ::marker for lists
|
||||
- Content property
|
||||
|
||||
### Priority 3: New HTML Modules (+7 lessons)
|
||||
|
||||
#### Semantic Structure (3 lessons)
|
||||
- Article vs Section
|
||||
- Header/Footer/Main
|
||||
- Nav and Aside
|
||||
|
||||
#### Media Elements (2 lessons)
|
||||
- Picture element (responsive images)
|
||||
- Audio/Video basics
|
||||
|
||||
#### Accessibility (2 lessons)
|
||||
- ARIA labels
|
||||
- Skip links
|
||||
- Focus management
|
||||
|
||||
---
|
||||
|
||||
## MDN Topics Reference
|
||||
|
||||
### CSS Topics from MDN (prioritized for interactive lessons)
|
||||
|
||||
**Layout Systems:**
|
||||
- [x] Flexbox (covered)
|
||||
- [x] Grid (covered)
|
||||
- [ ] Multi-column layout
|
||||
- [ ] Positioned layout (z-index, stacking)
|
||||
|
||||
**Visual Effects:**
|
||||
- [x] Colors (partially covered)
|
||||
- [ ] Filters (blur, brightness, contrast, drop-shadow)
|
||||
- [ ] Blend modes (mix-blend-mode, background-blend-mode)
|
||||
- [ ] Masking and clipping
|
||||
- [ ] Shapes (shape-outside)
|
||||
|
||||
**Typography:**
|
||||
- [x] Basic text (covered)
|
||||
- [ ] Web fonts (@font-face)
|
||||
- [ ] Variable fonts
|
||||
- [ ] Text decoration advanced
|
||||
|
||||
**Animations:**
|
||||
- [x] Transitions (covered)
|
||||
- [ ] Keyframe animations
|
||||
- [ ] Scroll-driven animations (experimental)
|
||||
- [ ] View transitions
|
||||
|
||||
**Advanced:**
|
||||
- [x] Custom properties (covered in units-variables)
|
||||
- [ ] Container queries
|
||||
- [ ] Anchor positioning (new)
|
||||
- [ ] Logical properties (for RTL support)
|
||||
|
||||
### HTML Topics from MDN
|
||||
|
||||
**Structural:**
|
||||
- [x] Basic elements (covered)
|
||||
- [x] Figure/figcaption (covered)
|
||||
- [ ] Article vs section semantics
|
||||
- [ ] Template element
|
||||
|
||||
**Interactive:**
|
||||
- [x] Details/Summary (covered)
|
||||
- [x] Dialog (have JSON, not active)
|
||||
- [ ] Datalist (have JSON, not active)
|
||||
- [ ] Progress/Meter (have JSON, not active)
|
||||
|
||||
**Forms:**
|
||||
- [x] Basic forms (covered)
|
||||
- [x] Validation (covered)
|
||||
- [x] Fieldset (have JSON, not active)
|
||||
- [ ] Input types exploration
|
||||
|
||||
**Media:**
|
||||
- [x] SVG basics (covered)
|
||||
- [ ] Picture element
|
||||
- [ ] srcset and sizes
|
||||
- [ ] Audio/Video
|
||||
|
||||
---
|
||||
|
||||
## Inactive Lesson Files (Ready to Activate)
|
||||
|
||||
These lesson files exist but aren't in the active module list:
|
||||
|
||||
| File | Lessons | Topic |
|
||||
|------|---------|-------|
|
||||
| 24-html-progress-meter.json | 3 | Progress/Meter elements |
|
||||
| 25-html-datalist.json | 2 | Datalist for autocomplete |
|
||||
| 27-html-dialog.json | 2 | Native dialog element |
|
||||
| 28-html-forms-fieldset.json | 3 | Fieldset/legend grouping |
|
||||
| 31-html-marquee.json | 3 | Marquee (deprecated but fun) |
|
||||
| **Total** | **13** | |
|
||||
|
||||
**Quick Win:** Activating these adds 13 lessons immediately → 79 total
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Foundation
|
||||
1. Design milestone UI component
|
||||
2. Implement milestone progress system
|
||||
3. Add milestone celebrations
|
||||
|
||||
### Week 2: Quick Wins
|
||||
4. Activate 5 inactive HTML modules (+13 lessons)
|
||||
5. Test and fix translations
|
||||
|
||||
### Week 3-4: New Content
|
||||
6. Create Filters & Effects module (+4)
|
||||
7. Create Positioning module (+4)
|
||||
8. Expand existing modules (+7)
|
||||
|
||||
### Final Polish
|
||||
9. Reach 100 lessons milestone
|
||||
10. Add milestone achievements to sidebar
|
||||
11. Update landing page messaging
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Milestone Data Structure
|
||||
|
||||
```js
|
||||
const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100];
|
||||
|
||||
function getMilestoneProgress(completed) {
|
||||
const reached = MILESTONES.filter(m => completed >= m);
|
||||
const current = reached[reached.length - 1] || 0;
|
||||
const next = MILESTONES.find(m => m > completed) || 100;
|
||||
|
||||
return {
|
||||
current,
|
||||
next,
|
||||
reached,
|
||||
percentToNext: ((completed - current) / (next - current)) * 100
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Display Update
|
||||
|
||||
```html
|
||||
<div class="milestone-progress">
|
||||
<div class="milestones">
|
||||
<span class="milestone reached" data-value="1">1</span>
|
||||
<span class="milestone reached" data-value="5">5</span>
|
||||
<span class="milestone current" data-value="10">10</span>
|
||||
<span class="milestone" data-value="20">20</span>
|
||||
<!-- ... -->
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 35%"></div>
|
||||
</div>
|
||||
<span class="progress-label">12 of 100 lessons</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] 100 total lessons
|
||||
- [ ] Milestone system implemented
|
||||
- [ ] All 6 languages have translations
|
||||
- [ ] Achievement celebrations working
|
||||
- [ ] Mobile responsive milestone UI
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768564909,
|
||||
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -9,13 +9,14 @@
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.npm
|
||||
gnumake
|
||||
claude-code
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "welcome",
|
||||
"title": "Welcome",
|
||||
"description": "Get started with Code Crispies",
|
||||
"mode": "html",
|
||||
"mode": "css",
|
||||
"difficulty": "beginner",
|
||||
"excludeFromProgress": true,
|
||||
"lessons": [
|
||||
|
||||
@@ -98,6 +98,53 @@
|
||||
"message": "Set letter-spacing to <kbd>1px</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "text-decoration",
|
||||
"title": "Text Decoration",
|
||||
"description": "The <kbd>text-decoration</kbd> property adds lines to text. Common values:<br><br>• <kbd>underline</kbd> — line below text<br>• <kbd>line-through</kbd> — strikethrough<br>• <kbd>none</kbd> — removes decoration (useful for links)<br><br>You can also style decorations with <kbd>text-decoration-color</kbd> and <kbd>text-decoration-style</kbd>.",
|
||||
"task": "Show the old price with a strikethrough. Add <kbd>text-decoration: line-through</kbd>.",
|
||||
"previewHTML": "<div class=\"price-box\"><span class=\"old-price\">$49.99</span><span class=\"new-price\">$29.99</span></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 1rem; } .price-box { display: flex; gap: 1rem; align-items: center; } .old-price { color: #999; font-size: 1rem; } .new-price { color: coral; font-size: 1.5rem; font-weight: bold; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".old-price {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"previewContainer": "preview-area",
|
||||
"solution": "text-decoration: line-through;",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "text-decoration", "expected": "line-through" },
|
||||
"message": "Set text-decoration to <kbd>line-through</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "text-shadow",
|
||||
"title": "Text Shadow",
|
||||
"description": "The <kbd>text-shadow</kbd> property adds shadow effects to text. The syntax is:<br><br><pre>text-shadow: x-offset y-offset blur color;</pre><br>Example: <kbd>text-shadow: 2px 2px 4px gray</kbd> creates a soft shadow offset down and right.",
|
||||
"task": "Add depth to the heading with <kbd>text-shadow: 2px 2px 4px gray</kbd>.",
|
||||
"previewHTML": "<h1 class=\"hero-title\">Welcome</h1>",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 2rem; background: linear-gradient(135deg, #667eea, #764ba2); } .hero-title { margin: 0; font-size: 3rem; color: white; text-align: center; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".hero-title {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"previewContainer": "preview-area",
|
||||
"solution": "text-shadow: 2px 2px 4px gray;",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "text-shadow",
|
||||
"message": "Use <kbd>text-shadow</kbd> property"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "2px 2px",
|
||||
"message": "Set offset to <kbd>2px 2px</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
92
lessons/09-gradients.json
Normal file
92
lessons/09-gradients.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "css-gradients",
|
||||
"title": "CSS Gradients",
|
||||
"description": "Create smooth color transitions with CSS gradients.",
|
||||
"difficulty": "intermediate",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "gradients-1",
|
||||
"title": "Linear Gradient",
|
||||
"description": "Gradients create smooth transitions between colors. The <kbd>linear-gradient()</kbd> function creates a gradient along a straight line.<br><br><strong>Basic syntax:</strong><br><pre>background: linear-gradient(color1, color2);</pre><br>By default, gradients flow from top to bottom.",
|
||||
"task": "Add a gradient background from <kbd>coral</kbd> to <kbd>gold</kbd>.",
|
||||
"previewHTML": "<div class=\"card\"><h3>Summer Sale</h3><p>Up to 50% off</p></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 2rem; border-radius: 12px; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } .card h3 { margin: 0 0 8px; font-size: 1.5rem; } .card p { margin: 0; opacity: 0.9; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".card {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "background: linear-gradient(coral, gold);",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "linear-gradient",
|
||||
"message": "Use <kbd>linear-gradient()</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "coral",
|
||||
"message": "Include <kbd>coral</kbd> as the first color"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "gold",
|
||||
"message": "Include <kbd>gold</kbd> as the second color"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gradients-2",
|
||||
"title": "Gradient Direction",
|
||||
"description": "Control the gradient direction by adding an angle or keyword before the colors.<br><br><strong>Keywords:</strong> <kbd>to right</kbd>, <kbd>to left</kbd>, <kbd>to bottom right</kbd><br><strong>Angles:</strong> <kbd>45deg</kbd>, <kbd>90deg</kbd>, <kbd>180deg</kbd><br><br><pre>background: linear-gradient(to right, blue, purple);</pre>",
|
||||
"task": "Make the gradient flow from left to right using <kbd>to right</kbd>.",
|
||||
"previewHTML": "<button class=\"btn\">Get Started</button>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; } .btn { padding: 1rem 2rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; color: white; cursor: pointer; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".btn {\n background: linear-gradient(",
|
||||
"initialCode": "",
|
||||
"codeSuffix": ", steelblue, mediumseagreen);\n}",
|
||||
"solution": "to right",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "to right",
|
||||
"message": "Add <kbd>to right</kbd> to set the direction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gradients-3",
|
||||
"title": "Radial Gradient",
|
||||
"description": "The <kbd>radial-gradient()</kbd> function creates a gradient that radiates from a center point outward in a circular or elliptical pattern.<br><br><pre>background: radial-gradient(circle, white, steelblue);</pre><br>Add <kbd>circle</kbd> for a perfect circular gradient.",
|
||||
"task": "Create a radial gradient from <kbd>white</kbd> to <kbd>steelblue</kbd>.",
|
||||
"previewHTML": "<div class=\"orb\"></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; display: flex; justify-content: center; } .orb { width: 150px; height: 150px; border-radius: 50%; box-shadow: 0 8px 32px rgba(70, 130, 180, 0.4); }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".orb {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "background: radial-gradient(circle, white, steelblue);",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "radial-gradient",
|
||||
"message": "Use <kbd>radial-gradient()</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "white",
|
||||
"message": "Start with <kbd>white</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "steelblue",
|
||||
"message": "End with <kbd>steelblue</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
108
lessons/11-filters.json
Normal file
108
lessons/11-filters.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "css-filters",
|
||||
"title": "CSS Filters",
|
||||
"description": "Apply visual effects like blur, brightness, and shadows with CSS filters.",
|
||||
"difficulty": "intermediate",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "filters-1",
|
||||
"title": "Blur Filter",
|
||||
"description": "The <kbd>filter</kbd> property applies visual effects to elements. The <kbd>blur()</kbd> function creates a Gaussian blur effect.<br><br><pre>filter: blur(4px);</pre><br>Higher values create more blur. This is great for backgrounds or creating depth.",
|
||||
"task": "Blur the background image using <kbd>filter: blur(4px)</kbd>.",
|
||||
"previewHTML": "<div class=\"bg\"></div><div class=\"content\"><h2>Welcome</h2></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; height: 200px; position: relative; overflow: hidden; } .bg { position: absolute; inset: 0; background: linear-gradient(45deg, coral, gold, steelblue); } .content { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; } .content h2 { color: white; text-shadow: 0 2px 8px rgba(0,0,0,0.3); margin: 0; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".bg {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "filter: blur(4px);",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "filter", "expected": "blur(4px)" },
|
||||
"message": "Set <kbd>filter: blur(4px)</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "filters-2",
|
||||
"title": "Grayscale Filter",
|
||||
"description": "The <kbd>grayscale()</kbd> function removes color from an element. Use values from <kbd>0%</kbd> (full color) to <kbd>100%</kbd> (fully grayscale).<br><br><pre>filter: grayscale(100%);</pre><br>Great for hover effects or disabled states.",
|
||||
"task": "Make the image grayscale with <kbd>filter: grayscale(100%)</kbd>.",
|
||||
"previewHTML": "<div class=\"photo\"></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .photo { width: 200px; height: 150px; background: linear-gradient(135deg, coral 0%, gold 50%, steelblue 100%); border-radius: 8px; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".photo {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "filter: grayscale(100%);",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "grayscale",
|
||||
"message": "Use <kbd>grayscale()</kbd> filter"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "100%",
|
||||
"message": "Set to <kbd>100%</kbd> for full grayscale"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "filters-3",
|
||||
"title": "Brightness Filter",
|
||||
"description": "The <kbd>brightness()</kbd> function adjusts how bright an element appears. Values below <kbd>100%</kbd> darken, above <kbd>100%</kbd> brighten.<br><br><pre>filter: brightness(150%);</pre>",
|
||||
"task": "Brighten the card with <kbd>filter: brightness(120%)</kbd>.",
|
||||
"previewHTML": "<div class=\"card\"><span>Featured</span></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #1a1a2e; } .card { padding: 2rem; background: linear-gradient(135deg, #4a4a6a, #2a2a4a); border-radius: 12px; text-align: center; } .card span { color: gold; font-weight: 600; text-transform: uppercase; letter-spacing: 2px; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".card {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "filter: brightness(120%);",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "brightness",
|
||||
"message": "Use <kbd>brightness()</kbd> filter"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "120%",
|
||||
"message": "Set to <kbd>120%</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "filters-4",
|
||||
"title": "Drop Shadow",
|
||||
"description": "The <kbd>drop-shadow()</kbd> filter creates a shadow that follows the shape of the element, including transparency. Unlike <kbd>box-shadow</kbd>, it works on images with transparent backgrounds.<br><br><pre>filter: drop-shadow(2px 4px 6px black);</pre>",
|
||||
"task": "Add a drop shadow with <kbd>filter: drop-shadow(4px 4px 8px gray)</kbd>.",
|
||||
"previewHTML": "<div class=\"icon\">★</div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; display: flex; justify-content: center; } .icon { font-size: 4rem; color: gold; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".icon {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "filter: drop-shadow(4px 4px 8px gray);",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "drop-shadow",
|
||||
"message": "Use <kbd>drop-shadow()</kbd> filter"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "4px 4px 8px",
|
||||
"message": "Set shadow offset and blur"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
98
lessons/12-positioning.json
Normal file
98
lessons/12-positioning.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "css-positioning",
|
||||
"title": "CSS Positioning",
|
||||
"description": "Control element placement with CSS positioning properties.",
|
||||
"difficulty": "intermediate",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "position-1",
|
||||
"title": "Relative Position",
|
||||
"description": "The <kbd>position</kbd> property controls how elements are placed. <kbd>relative</kbd> keeps the element in normal flow but allows you to offset it with <kbd>top</kbd>, <kbd>right</kbd>, <kbd>bottom</kbd>, <kbd>left</kbd>.<br><br><pre>.box {\n position: relative;\n top: 10px;\n}</pre>",
|
||||
"task": "Make the badge position relative so we can offset it.",
|
||||
"previewHTML": "<div class=\"card\"><span class=\"badge\">NEW</span><h3>Product</h3></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; background: white; border: 2px solid #eee; border-radius: 8px; } .card h3 { margin: 0; } .badge { display: inline-block; padding: 2px 8px; background: coral; color: white; font-size: 0.7rem; font-weight: bold; border-radius: 4px; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".badge {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "position: relative;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "position", "expected": "relative" },
|
||||
"message": "Set <kbd>position: relative</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "position-2",
|
||||
"title": "Offset Properties",
|
||||
"description": "With <kbd>position: relative</kbd>, use offset properties to nudge the element from its original position:<br><br><kbd>top</kbd> - pushes down from top<br><kbd>left</kbd> - pushes right from left<br><br>Negative values move in the opposite direction.",
|
||||
"task": "Move the badge up with <kbd>top: -8px</kbd>.",
|
||||
"previewHTML": "<div class=\"card\"><span class=\"badge\">NEW</span><h3>Product</h3></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; background: white; border: 2px solid #eee; border-radius: 8px; } .card h3 { margin: 0; } .badge { display: inline-block; padding: 2px 8px; background: coral; color: white; font-size: 0.7rem; font-weight: bold; border-radius: 4px; position: relative; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".badge {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "top: -8px;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "top", "expected": "-8px" },
|
||||
"message": "Set <kbd>top: -8px</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "position-3",
|
||||
"title": "Absolute Position",
|
||||
"description": "<kbd>position: absolute</kbd> removes the element from normal flow and positions it relative to its nearest positioned ancestor (or the viewport if none exists).<br><br>Always set a parent to <kbd>position: relative</kbd> to contain absolute children.",
|
||||
"task": "Position the close button absolutely.",
|
||||
"previewHTML": "<div class=\"modal\"><button class=\"close\">×</button><h3>Modal</h3><p>Content here</p></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .modal { position: relative; padding: 2rem; background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); max-width: 250px; } .modal h3 { margin: 0 0 8px; } .modal p { margin: 0; color: #666; } .close { width: 32px; height: 32px; border: none; background: #f5f5f5; border-radius: 50%; font-size: 1.2rem; cursor: pointer; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".close {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "position: absolute;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "position", "expected": "absolute" },
|
||||
"message": "Set <kbd>position: absolute</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "position-4",
|
||||
"title": "Placing Absolute Elements",
|
||||
"description": "Combine <kbd>position: absolute</kbd> with offset properties to place elements precisely.<br><br><pre>.close {\n position: absolute;\n top: 8px;\n right: 8px;\n}</pre>",
|
||||
"task": "Move the close button to the top right corner with <kbd>top: 8px</kbd> and <kbd>right: 8px</kbd>.",
|
||||
"previewHTML": "<div class=\"modal\"><button class=\"close\">×</button><h3>Modal</h3><p>Content here</p></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .modal { position: relative; padding: 2rem; background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); max-width: 250px; } .modal h3 { margin: 0 0 8px; } .modal p { margin: 0; color: #666; } .close { position: absolute; width: 32px; height: 32px; border: none; background: #f5f5f5; border-radius: 50%; font-size: 1.2rem; cursor: pointer; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".close {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "top: 8px;\n right: 8px;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "top", "expected": "8px" },
|
||||
"message": "Set <kbd>top: 8px</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "right", "expected": "8px" },
|
||||
"message": "Set <kbd>right: 8px</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
113
lessons/13-pseudo-elements.json
Normal file
113
lessons/13-pseudo-elements.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "css-pseudo-elements",
|
||||
"title": "CSS Pseudo-elements",
|
||||
"description": "Create decorative elements and style specific parts of content with pseudo-elements.",
|
||||
"difficulty": "intermediate",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "pseudo-1",
|
||||
"title": "The ::before Element",
|
||||
"description": "Pseudo-elements let you style specific parts of an element. <kbd>::before</kbd> creates a virtual element as the first child.<br><br>It requires the <kbd>content</kbd> property to display anything (even if empty).<br><br><pre>.item::before {\n content: \"→ \";\n}</pre>",
|
||||
"task": "Add a bullet before each list item using <kbd>::before</kbd> with <kbd>content: \"• \"</kbd>.",
|
||||
"previewHTML": "<ul class=\"list\"><li>First item</li><li>Second item</li><li>Third item</li></ul>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .list { list-style: none; padding: 0; margin: 0; } .list li { padding: 8px 0; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".list li::before {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "content: \"• \";",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "content",
|
||||
"message": "Use the <kbd>content</kbd> property"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "•",
|
||||
"message": "Add a bullet character <kbd>•</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pseudo-2",
|
||||
"title": "Styling ::before",
|
||||
"description": "Pseudo-elements can be styled like any element. Add color, size, margins, and more.<br><br><pre>.item::before {\n content: \"★\";\n color: gold;\n margin-right: 8px;\n}</pre>",
|
||||
"task": "Style the bullet with <kbd>color: coral</kbd>.",
|
||||
"previewHTML": "<ul class=\"list\"><li>First item</li><li>Second item</li><li>Third item</li></ul>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .list { list-style: none; padding: 0; margin: 0; } .list li { padding: 8px 0; } .list li::before { content: \"• \"; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".list li::before {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "color: coral;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "color", "expected": "coral" },
|
||||
"message": "Set <kbd>color: coral</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pseudo-3",
|
||||
"title": "The ::after Element",
|
||||
"description": "<kbd>::after</kbd> works like <kbd>::before</kbd> but inserts content as the last child. Common uses include badges, icons, or decorative elements.<br><br><pre>.new::after {\n content: \" ✓\";\n color: green;\n}</pre>",
|
||||
"task": "Add a checkmark after completed items with <kbd>content: \" ✓\"</kbd>.",
|
||||
"previewHTML": "<ul class=\"list\"><li class=\"done\">Buy groceries</li><li class=\"done\">Walk the dog</li><li>Read a book</li></ul>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .list { list-style: none; padding: 0; margin: 0; } .list li { padding: 8px 0; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".done::after {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "content: \" ✓\";",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "content",
|
||||
"message": "Use the <kbd>content</kbd> property"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "✓",
|
||||
"message": "Add a checkmark <kbd>✓</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pseudo-4",
|
||||
"title": "Decorative Lines",
|
||||
"description": "Pseudo-elements with <kbd>content: \"\"</kbd> can create decorative shapes when combined with width, height, and background.<br><br><pre>.title::after {\n content: \"\";\n display: block;\n width: 50px;\n height: 3px;\n background: coral;\n}</pre>",
|
||||
"task": "Create an underline decoration with <kbd>width: 40px</kbd>, <kbd>height: 3px</kbd>, and <kbd>background: steelblue</kbd>.",
|
||||
"previewHTML": "<h2 class=\"title\">About Us</h2><p>We build great things.</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .title { margin: 0 0 1rem; } .title::after { content: \"\"; display: block; margin-top: 8px; } p { margin: 0; color: #666; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": ".title::after {\n ",
|
||||
"initialCode": "",
|
||||
"codeSuffix": "\n}",
|
||||
"solution": "width: 40px;\n height: 3px;\n background: steelblue;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "width", "expected": "40px" },
|
||||
"message": "Set <kbd>width: 40px</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "height", "expected": "3px" },
|
||||
"message": "Set <kbd>height: 3px</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "property_value",
|
||||
"value": { "property": "background", "expected": "steelblue" },
|
||||
"message": "Set <kbd>background: steelblue</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -39,6 +39,54 @@
|
||||
"message": "Add 3 rows (1 header + 2 data rows)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table-sections",
|
||||
"title": "Table Sections",
|
||||
"description": "Semantic table sections improve accessibility and allow for separate styling:<br><br>• <kbd><thead></kbd> — header section<br>• <kbd><tbody></kbd> — main content<br>• <kbd><tfoot></kbd> — footer (totals, summaries)",
|
||||
"task": "Wrap the header row in <kbd><thead></kbd> and data rows in <kbd><tbody></kbd>.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } table { border-collapse: collapse; width: 100%; max-width: 350px; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } th, td { padding: 12px 16px; text-align: left; } thead { background: steelblue; color: white; } tbody tr:nth-child(even) { background: #f8f9fa; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<table>\n <tr>\n <th>Name</th>\n <th>Score</th>\n </tr>\n <tr>\n <td>Alice</td>\n <td>95</td>\n </tr>\n <tr>\n <td>Bob</td>\n <td>87</td>\n </tr>\n</table>",
|
||||
"solution": "<table>\n <thead>\n <tr>\n <th>Name</th>\n <th>Score</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Alice</td>\n <td>95</td>\n </tr>\n <tr>\n <td>Bob</td>\n <td>87</td>\n </tr>\n </tbody>\n</table>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "thead",
|
||||
"message": "Add a <kbd><thead></kbd> section for the header"
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "tbody",
|
||||
"message": "Add a <kbd><tbody></kbd> section for the data"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table-colspan",
|
||||
"title": "Spanning Columns",
|
||||
"description": "The <kbd>colspan</kbd> attribute lets a cell span multiple columns. This is useful for headers that group multiple columns or footer totals.<br><br><pre><td colspan=\"2\">...</td></pre>",
|
||||
"task": "Add a footer row that spans both columns using <kbd>colspan=\"2\"</kbd>.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } table { border-collapse: collapse; width: 100%; max-width: 350px; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #eee; } thead { background: steelblue; color: white; } tfoot { background: #f0f0f0; font-weight: 600; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<table>\n <thead>\n <tr>\n <th>Item</th>\n <th>Price</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Coffee</td>\n <td>$4</td>\n </tr>\n <tr>\n <td>Cake</td>\n <td>$6</td>\n </tr>\n </tbody>\n</table>",
|
||||
"solution": "<table>\n <thead>\n <tr>\n <th>Item</th>\n <th>Price</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Coffee</td>\n <td>$4</td>\n </tr>\n <tr>\n <td>Cake</td>\n <td>$6</td>\n </tr>\n </tbody>\n <tfoot>\n <tr>\n <td colspan=\"2\">Total: $10</td>\n </tr>\n </tfoot>\n</table>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "tfoot",
|
||||
"message": "Add a <kbd><tfoot></kbd> section"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "colspan",
|
||||
"message": "Use <kbd>colspan</kbd> to span columns"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
88
lessons/33-html-semantic.json
Normal file
88
lessons/33-html-semantic.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "html-semantic",
|
||||
"title": "Semantic HTML",
|
||||
"mode": "html",
|
||||
"description": "Use meaningful HTML elements to structure content properly.",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "semantic-1",
|
||||
"title": "The <article> Element",
|
||||
"description": "The <kbd><article></kbd> element represents self-contained content that could be distributed independently, like a blog post, news article, or comment.<br><br><pre><article>\n <h2>Article Title</h2>\n <p>Article content...</p>\n</article></pre>",
|
||||
"task": "Wrap the blog post content in an <kbd><article></kbd> element.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } article { padding: 1rem; background: #f9f9f9; border-left: 4px solid steelblue; border-radius: 4px; } h2 { margin: 0 0 8px; color: steelblue; } p { margin: 0; color: #555; line-height: 1.5; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": "",
|
||||
"initialCode": "<h2>My First Post</h2>\n<p>This is a blog post about learning HTML.</p>",
|
||||
"codeSuffix": "",
|
||||
"solution": "<article>\n<h2>My First Post</h2>\n<p>This is a blog post about learning HTML.</p>\n</article>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "<article>",
|
||||
"message": "Add an opening <kbd><article></kbd> tag"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "</article>",
|
||||
"message": "Add a closing <kbd></article></kbd> tag"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "semantic-2",
|
||||
"title": "The <section> Element",
|
||||
"description": "The <kbd><section></kbd> element represents a thematic grouping of content, typically with a heading. Use it to divide a page into logical sections.<br><br><pre><section>\n <h2>Features</h2>\n <p>Our product features...</p>\n</section></pre>",
|
||||
"task": "Wrap the features content in a <kbd><section></kbd> element.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } section { padding: 1rem; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 8px; } h2 { margin: 0 0 12px; } ul { margin: 0; padding-left: 1.5rem; } li { margin: 4px 0; color: #444; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": "",
|
||||
"initialCode": "<h2>Features</h2>\n<ul>\n <li>Fast performance</li>\n <li>Easy to use</li>\n</ul>",
|
||||
"codeSuffix": "",
|
||||
"solution": "<section>\n<h2>Features</h2>\n<ul>\n <li>Fast performance</li>\n <li>Easy to use</li>\n</ul>\n</section>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "<section>",
|
||||
"message": "Add an opening <kbd><section></kbd> tag"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "</section>",
|
||||
"message": "Add a closing <kbd></section></kbd> tag"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "semantic-3",
|
||||
"title": "The <aside> Element",
|
||||
"description": "The <kbd><aside></kbd> element represents content tangentially related to the main content, like sidebars, pull quotes, or related links.<br><br><pre><aside>\n <h3>Related</h3>\n <ul>...</ul>\n</aside></pre>",
|
||||
"task": "Wrap the related links in an <kbd><aside></kbd> element.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } aside { padding: 1rem; background: #fff8e7; border: 1px solid #ffe0a6; border-radius: 8px; } h3 { margin: 0 0 8px; color: #b8860b; font-size: 0.9rem; text-transform: uppercase; } ul { margin: 0; padding-left: 1.2rem; } li { margin: 4px 0; } a { color: #b8860b; }",
|
||||
"sandboxCSS": "",
|
||||
"codePrefix": "",
|
||||
"initialCode": "<h3>Related Posts</h3>\n<ul>\n <li><a href=\"#\">CSS Basics</a></li>\n <li><a href=\"#\">HTML Tips</a></li>\n</ul>",
|
||||
"codeSuffix": "",
|
||||
"solution": "<aside>\n<h3>Related Posts</h3>\n<ul>\n <li><a href=\"#\">CSS Basics</a></li>\n <li><a href=\"#\">HTML Tips</a></li>\n</ul>\n</aside>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "<aside>",
|
||||
"message": "Add an opening <kbd><aside></kbd> tag"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "</aside>",
|
||||
"message": "Add a closing <kbd></aside></kbd> tag"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
197
lessons/40-markdown-basics.json
Normal file
197
lessons/40-markdown-basics.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "markdown-basics",
|
||||
"title": "Markdown Basics",
|
||||
"description": "Learn to format text documents with Markdown, a simple and readable markup language used everywhere from GitHub to note-taking apps.",
|
||||
"mode": "markdown",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "md-headings",
|
||||
"title": "Headings",
|
||||
"description": "Markdown uses hash symbols <kbd>#</kbd> to create headings. One <kbd>#</kbd> creates the largest heading (h1), two <kbd>##</kbd> creates a smaller heading (h2), and so on up to six levels.<br><br><pre># Main Title\n## Section\n### Subsection</pre>",
|
||||
"task": "Create a main heading by typing <kbd># Hello</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "# Hello",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "^#\\s+.+",
|
||||
"message": "Start with <kbd>#</kbd> followed by a space and your heading text"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "Hello",
|
||||
"message": "Your heading should contain <kbd>Hello</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-heading-levels",
|
||||
"title": "Heading Levels",
|
||||
"description": "Use more <kbd>#</kbd> symbols for smaller headings. <kbd>##</kbd> creates an h2, <kbd>###</kbd> an h3. This creates a clear document structure with visual hierarchy.",
|
||||
"task": "Create an h2 heading with <kbd>## About</kbd> followed by an h3 heading with <kbd>### Details</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "## About\n\n### Details",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "^##\\s+About",
|
||||
"message": "Start with <kbd>## About</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "###\\s+Details",
|
||||
"message": "Add <kbd>### Details</kbd> for the h3 heading"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-bold",
|
||||
"title": "Bold Text",
|
||||
"description": "Wrap text in double asterisks <kbd>**</kbd> or double underscores <kbd>__</kbd> to make it <strong>bold</strong>. This emphasizes important words or phrases.",
|
||||
"task": "Make the word <kbd>important</kbd> bold by wrapping it with <kbd>**</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "This is important text.",
|
||||
"solution": "This is **important** text.",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\*\\*important\\*\\*",
|
||||
"message": "Wrap <kbd>important</kbd> with double asterisks: <kbd>**important**</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-italic",
|
||||
"title": "Italic Text",
|
||||
"description": "Wrap text in single asterisks <kbd>*</kbd> or single underscores <kbd>_</kbd> to make it <em>italic</em>. Use this for subtle emphasis or titles of works.",
|
||||
"task": "Make the word <kbd>elegant</kbd> italic by wrapping it with <kbd>*</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "A simple and elegant solution.",
|
||||
"solution": "A simple and *elegant* solution.",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\*elegant\\*",
|
||||
"message": "Wrap <kbd>elegant</kbd> with single asterisks: <kbd>*elegant*</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "not_contains",
|
||||
"value": "**elegant**",
|
||||
"message": "Use single asterisks for italic, not double"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-unordered-list",
|
||||
"title": "Bullet Lists",
|
||||
"description": "Create bullet lists using <kbd>-</kbd>, <kbd>*</kbd>, or <kbd>+</kbd> at the start of each line. Each item goes on its own line.",
|
||||
"task": "Create a bullet list with three items: <kbd>Apple</kbd>, <kbd>Banana</kbd>, <kbd>Cherry</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "- Apple\n- Banana\n- Cherry",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "^[-*+]\\s+Apple",
|
||||
"message": "Start with a dash, asterisk, or plus followed by <kbd>Apple</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "[-*+]\\s+Banana",
|
||||
"message": "Add <kbd>Banana</kbd> as a list item"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "[-*+]\\s+Cherry",
|
||||
"message": "Add <kbd>Cherry</kbd> as a list item"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-ordered-list",
|
||||
"title": "Numbered Lists",
|
||||
"description": "Create numbered lists by starting lines with <kbd>1.</kbd>, <kbd>2.</kbd>, etc. Markdown automatically numbers them in sequence.",
|
||||
"task": "Create a numbered list: <kbd>Wake up</kbd>, <kbd>Eat breakfast</kbd>, <kbd>Start coding</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "1. Wake up\n2. Eat breakfast\n3. Start coding",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\d+\\.\\s+Wake up",
|
||||
"message": "Start with a number and period: <kbd>1. Wake up</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\d+\\.\\s+Eat breakfast",
|
||||
"message": "Add <kbd>Eat breakfast</kbd> as a numbered item"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\d+\\.\\s+Start coding",
|
||||
"message": "Add <kbd>Start coding</kbd> as a numbered item"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-links",
|
||||
"title": "Links",
|
||||
"description": "Create links with <kbd>[text](url)</kbd>. The text in brackets is what readers see; the URL in parentheses is where they go when clicked.",
|
||||
"task": "Create a link that shows <kbd>Google</kbd> and goes to <kbd>https://google.com</kbd>",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "[Google](https://google.com)",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\[Google\\]\\(https?://google\\.com\\)",
|
||||
"message": "Use the format <kbd>[Google](https://google.com)</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "md-inline-code",
|
||||
"title": "Inline Code",
|
||||
"description": "Wrap text in backticks <kbd>`</kbd> to format it as code. This is useful for variable names, commands, or short code snippets in your text.",
|
||||
"task": "Format <kbd>npm install</kbd> as inline code using backticks",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "Run npm install to install dependencies.",
|
||||
"solution": "Run `npm install` to install dependencies.",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "`npm install`",
|
||||
"message": "Wrap <kbd>npm install</kbd> with backticks: <kbd>`npm install`</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
98
lessons/50-js-variables.json
Normal file
98
lessons/50-js-variables.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "js-variables",
|
||||
"title": "JS Variables",
|
||||
"description": "Learn to declare variables with let and const, work with strings and numbers, and use template literals to build dynamic text.",
|
||||
"mode": "javascript",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "js-const",
|
||||
"title": "Constants",
|
||||
"description": "Use <kbd>const</kbd> to declare a variable that cannot be reassigned. Constants are great for values that stay the same throughout your program.",
|
||||
"task": "Declare a constant named <kbd>name</kbd> with the value <kbd>\"Ada\"</kbd>",
|
||||
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "const name = \"Ada\";",
|
||||
"codePrefix": "",
|
||||
"codeSuffix": "\ndocument.getElementById(\"out\").textContent = name;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "const",
|
||||
"message": "Use <kbd>const</kbd> to declare the variable"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "const\\s+name\\s*=",
|
||||
"message": "Name your constant <kbd>name</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "[\"']Ada[\"']",
|
||||
"message": "Set the value to <kbd>\"Ada\"</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "js-let",
|
||||
"title": "Let Variables",
|
||||
"description": "Use <kbd>let</kbd> to declare a variable that can be reassigned later. This is useful when you need to update a value.",
|
||||
"task": "Declare a variable <kbd>score</kbd> with <kbd>let</kbd> and set it to <kbd>0</kbd>",
|
||||
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "let score = 0;",
|
||||
"codePrefix": "",
|
||||
"codeSuffix": "\ndocument.getElementById(\"out\").textContent = \"Score: \" + score;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "let",
|
||||
"message": "Use <kbd>let</kbd> to declare the variable"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "let\\s+score\\s*=\\s*0",
|
||||
"message": "Set <kbd>score</kbd> to <kbd>0</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "js-template",
|
||||
"title": "Template Literals",
|
||||
"description": "Template literals use backticks <kbd>`</kbd> and <kbd>${}</kbd> to embed expressions inside strings. They make building dynamic text much easier than string concatenation.",
|
||||
"task": "Create a <kbd>const msg</kbd> using a template literal: <kbd>`Hi, ${name}!`</kbd>",
|
||||
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "const msg = `Hi, ${name}!`;",
|
||||
"codePrefix": "const name = \"Ada\";\n",
|
||||
"codeSuffix": "\ndocument.getElementById(\"out\").textContent = msg;",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "${",
|
||||
"message": "Use <kbd>${}</kbd> to embed the variable inside the template"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "`[^`]*\\$\\{\\s*name\\s*\\}[^`]*`",
|
||||
"message": "Embed <kbd>name</kbd> inside a template literal with backticks"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "const\\s+msg\\s*=",
|
||||
"message": "Assign the result to a constant named <kbd>msg</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
93
lessons/51-js-dom.json
Normal file
93
lessons/51-js-dom.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "js-dom",
|
||||
"title": "JS DOM",
|
||||
"description": "Learn to select HTML elements with querySelector, change their text content, and modify their styles using JavaScript.",
|
||||
"mode": "javascript",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "js-query",
|
||||
"title": "Select an Element",
|
||||
"description": "Use <kbd>document.querySelector()</kbd> to find an element by its CSS selector. It returns the first matching element.",
|
||||
"task": "Select the element with id <kbd>box</kbd> and store it in a <kbd>const el</kbd>",
|
||||
"previewHTML": "<div id=\"box\" style=\"width:80px;height:80px;background:coral;border-radius:8px;\"></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "const el = document.querySelector(\"#box\");",
|
||||
"codePrefix": "",
|
||||
"codeSuffix": "\nif (el) el.textContent = \"Found!\";",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "querySelector",
|
||||
"message": "Use <kbd>document.querySelector()</kbd> to select the element"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "querySelector\\s*\\([\"']#box[\"']\\)",
|
||||
"message": "Pass <kbd>\"#box\"</kbd> as the selector"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "const\\s+el\\s*=",
|
||||
"message": "Store the result in a constant named <kbd>el</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "js-text",
|
||||
"title": "Change Text",
|
||||
"description": "The <kbd>textContent</kbd> property lets you read or change the text inside an element. Setting it replaces all the element's text.",
|
||||
"task": "Set the <kbd>textContent</kbd> of <kbd>el</kbd> to <kbd>\"Hello!\"</kbd>",
|
||||
"previewHTML": "<p id=\"msg\">Old text</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "el.textContent = \"Hello!\";",
|
||||
"codePrefix": "const el = document.querySelector(\"#msg\");\n",
|
||||
"codeSuffix": "",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "textContent",
|
||||
"message": "Use the <kbd>textContent</kbd> property"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "el\\.textContent\\s*=\\s*[\"']Hello![\"']",
|
||||
"message": "Set <kbd>el.textContent</kbd> to <kbd>\"Hello!\"</kbd>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "js-style",
|
||||
"title": "Change Style",
|
||||
"description": "Access an element's inline styles through the <kbd>style</kbd> property. CSS properties use camelCase in JavaScript, so <kbd>background-color</kbd> becomes <kbd>backgroundColor</kbd>.",
|
||||
"task": "Set <kbd>el.style.backgroundColor</kbd> to <kbd>\"gold\"</kbd>",
|
||||
"previewHTML": "<div id=\"box\" style=\"width:80px;height:80px;background:coral;border-radius:8px;\"></div>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "el.style.backgroundColor = \"gold\";",
|
||||
"codePrefix": "const el = document.querySelector(\"#box\");\n",
|
||||
"codeSuffix": "",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "style.backgroundColor",
|
||||
"message": "Use <kbd>el.style.backgroundColor</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "\\.style\\.backgroundColor\\s*=\\s*[\"']gold[\"']",
|
||||
"message": "Set the background color to <kbd>\"gold\"</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
103
lessons/52-js-events.json
Normal file
103
lessons/52-js-events.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "js-events",
|
||||
"title": "JS Events",
|
||||
"description": "Learn to respond to user actions by adding event listeners for clicks, toggling classes, and updating the page dynamically.",
|
||||
"mode": "javascript",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "js-click",
|
||||
"title": "Click Handler",
|
||||
"description": "Use <kbd>addEventListener(\"click\", ...)</kbd> to run code when an element is clicked. The first argument is the event type and the second is a callback function.",
|
||||
"task": "Add a <kbd>click</kbd> event listener to <kbd>btn</kbd> that sets <kbd>out.textContent</kbd> to <kbd>\"Clicked!\"</kbd>",
|
||||
"previewHTML": "<button id=\"btn\" style=\"padding:8px 16px;font-size:1rem;\">Click me</button>\n<p id=\"out\">Waiting...</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "btn.addEventListener(\"click\", () => {\n out.textContent = \"Clicked!\";\n});",
|
||||
"codePrefix": "const btn = document.querySelector(\"#btn\");\nconst out = document.querySelector(\"#out\");\n",
|
||||
"codeSuffix": "",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "addEventListener",
|
||||
"message": "Use <kbd>addEventListener</kbd> to listen for events"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "addEventListener\\s*\\(\\s*[\"']click[\"']",
|
||||
"message": "Listen for the <kbd>\"click\"</kbd> event"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "textContent\\s*=\\s*[\"']Clicked![\"']",
|
||||
"message": "Set <kbd>out.textContent</kbd> to <kbd>\"Clicked!\"</kbd> inside the handler"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "js-toggle",
|
||||
"title": "Toggle a Class",
|
||||
"description": "Use <kbd>classList.toggle()</kbd> to add a class if it's missing or remove it if it's present. This is perfect for on/off states like toggling dark mode or active states.",
|
||||
"task": "Inside the click handler, call <kbd>box.classList.toggle(\"on\")</kbd>",
|
||||
"previewHTML": "<div id=\"box\" style=\"width:80px;height:80px;background:coral;border-radius:8px;transition:background 0.3s;\"></div>\n<style>.on { background: mediumseagreen !important; }</style>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "box.addEventListener(\"click\", () => {\n box.classList.toggle(\"on\");\n});",
|
||||
"codePrefix": "const box = document.querySelector(\"#box\");\n",
|
||||
"codeSuffix": "",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "classList.toggle",
|
||||
"message": "Use <kbd>classList.toggle()</kbd> to toggle the class"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "classList\\.toggle\\s*\\(\\s*[\"']on[\"']\\s*\\)",
|
||||
"message": "Toggle the class <kbd>\"on\"</kbd>"
|
||||
},
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "addEventListener",
|
||||
"message": "Use <kbd>addEventListener</kbd> to listen for clicks"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "js-counter",
|
||||
"title": "Simple Counter",
|
||||
"description": "Combine variables and event listeners to build interactive features. Use <kbd>let</kbd> for a value that changes, and update the display each time the button is clicked.",
|
||||
"task": "In the click handler, increment <kbd>count</kbd> by 1 and set <kbd>out.textContent</kbd> to <kbd>count</kbd>",
|
||||
"previewHTML": "<button id=\"btn\" style=\"padding:8px 16px;font-size:1rem;\">Add 1</button>\n<p id=\"out\">0</p>",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "",
|
||||
"solution": "btn.addEventListener(\"click\", () => {\n count++;\n out.textContent = count;\n});",
|
||||
"codePrefix": "const btn = document.querySelector(\"#btn\");\nconst out = document.querySelector(\"#out\");\nlet count = 0;\n",
|
||||
"codeSuffix": "",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "contains",
|
||||
"value": "addEventListener",
|
||||
"message": "Use <kbd>addEventListener</kbd> to listen for clicks"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "count\\s*\\+\\+|count\\s*\\+=\\s*1|count\\s*=\\s*count\\s*\\+\\s*1",
|
||||
"message": "Increment <kbd>count</kbd> by 1 (use <kbd>count++</kbd>)"
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"value": "out\\.textContent\\s*=\\s*count",
|
||||
"message": "Update the display with <kbd>out.textContent = count</kbd>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "welcome",
|
||||
"title": "مرحباً",
|
||||
"description": "ابدأ مع Code Crispies",
|
||||
"mode": "html",
|
||||
"mode": "css",
|
||||
"difficulty": "beginner",
|
||||
"excludeFromProgress": true,
|
||||
"lessons": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "welcome",
|
||||
"title": "Willkommen",
|
||||
"description": "Erste Schritte mit Code Crispies",
|
||||
"mode": "html",
|
||||
"mode": "css",
|
||||
"difficulty": "beginner",
|
||||
"excludeFromProgress": true,
|
||||
"lessons": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "welcome",
|
||||
"title": "Bienvenido",
|
||||
"description": "Comienza con Code Crispies",
|
||||
"mode": "html",
|
||||
"mode": "css",
|
||||
"difficulty": "beginner",
|
||||
"excludeFromProgress": true,
|
||||
"lessons": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "welcome",
|
||||
"title": "Witaj",
|
||||
"description": "Rozpocznij przygodę z Code Crispies",
|
||||
"mode": "html",
|
||||
"mode": "css",
|
||||
"difficulty": "beginner",
|
||||
"excludeFromProgress": true,
|
||||
"lessons": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "welcome",
|
||||
"title": "Ласкаво просимо",
|
||||
"description": "Почніть з Code Crispies",
|
||||
"mode": "html",
|
||||
"mode": "css",
|
||||
"difficulty": "beginner",
|
||||
"excludeFromProgress": true,
|
||||
"lessons": [
|
||||
|
||||
194
package-lock.json
generated
194
package-lock.json
generated
@@ -7,17 +7,21 @@
|
||||
"": {
|
||||
"name": "code-crispies",
|
||||
"version": "1.0.0",
|
||||
"license": "Copyright 2025 (c) Michael Czechowski",
|
||||
"license": "Copyright 2026 (c) Michael Czechowski",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.4",
|
||||
"@emmetio/codemirror6-plugin": "^0.4.0",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"marked": "^17.0.1",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -155,7 +159,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
|
||||
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -168,7 +171,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
|
||||
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
@@ -181,7 +183,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
@@ -195,7 +196,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
@@ -209,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
||||
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
@@ -223,12 +223,26 @@
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
||||
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
||||
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -265,7 +279,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -287,7 +300,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
||||
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -383,7 +395,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -407,7 +418,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -973,9 +983,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
|
||||
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
|
||||
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
@@ -1029,6 +1039,16 @@
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
|
||||
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
@@ -1354,6 +1374,86 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.90.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
|
||||
"integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.90.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
|
||||
"integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.90.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
|
||||
"integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.90.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
|
||||
"integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.90.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
|
||||
"integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.90.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
|
||||
"integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.90.1",
|
||||
"@supabase/functions-js": "2.90.1",
|
||||
"@supabase/postgrest-js": "2.90.1",
|
||||
"@supabase/realtime-js": "2.90.1",
|
||||
"@supabase/storage-js": "2.90.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -1433,6 +1533,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
|
||||
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||
@@ -2092,6 +2216,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -2222,7 +2355,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -2319,6 +2451,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -2465,7 +2609,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2991,13 +3134,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -3096,7 +3250,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -3374,7 +3527,6 @@
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -37,11 +37,15 @@
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.4",
|
||||
"@emmetio/codemirror6-plugin": "^0.4.0",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"marked": "^17.0.1",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["css", "tailwind", "html"],
|
||||
"description": "Whether this module teaches CSS, Tailwind, or HTML"
|
||||
"enum": ["css", "tailwind", "html", "markdown", "javascript"],
|
||||
"description": "Whether this module teaches CSS, Tailwind, HTML, Markdown, or JavaScript"
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["css", "tailwind", "html"],
|
||||
"enum": ["css", "tailwind", "html", "markdown", "javascript"],
|
||||
"description": "Override module mode for individual lessons"
|
||||
},
|
||||
"tailwindConfig": {
|
||||
|
||||
569
src/app.js
569
src/app.js
@@ -1,11 +1,12 @@
|
||||
import { LessonEngine } from "./impl/LessonEngine.js";
|
||||
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js";
|
||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderDifficultyBadge } from "./helpers/renderer.js";
|
||||
import { loadModules } from "./config/lessons.js";
|
||||
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
|
||||
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
|
||||
import { sections, getSection, getModuleSection, getModulesBySection } from "./config/sections.js";
|
||||
import { getRandomTemplate } from "./config/playground-templates.js";
|
||||
import { initAuth, handleOAuthCallback } from "./auth.js";
|
||||
|
||||
// CodeMirror imports for syntax highlighting
|
||||
import { EditorState } from "@codemirror/state";
|
||||
@@ -17,6 +18,9 @@ import { css } from "@codemirror/lang-css";
|
||||
function track(eventName, eventData = {}) {
|
||||
if (typeof umami !== "undefined" && umami.track) {
|
||||
umami.track(eventName, eventData);
|
||||
console.debug("Track:", eventName, eventData);
|
||||
} else {
|
||||
console.debug("Track blocked (umami unavailable):", eventName, eventData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +151,7 @@ const elements = {
|
||||
previewSection: document.querySelector(".preview-section"),
|
||||
prevBtn: document.getElementById("prev-btn"),
|
||||
nextBtn: document.getElementById("next-btn"),
|
||||
gameControls: document.querySelector(".game-controls"),
|
||||
levelIndicator: document.getElementById("level-indicator"),
|
||||
headerLevelPill: document.getElementById("header-level-pill"),
|
||||
|
||||
@@ -156,8 +161,12 @@ const elements = {
|
||||
closeSidebar: document.getElementById("close-sidebar"),
|
||||
moduleList: document.getElementById("module-list"),
|
||||
footerLessonLinks: document.getElementById("footer-lesson-links"),
|
||||
refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"),
|
||||
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
|
||||
progressFill: document.getElementById("progress-fill"),
|
||||
progressText: document.getElementById("progress-text"),
|
||||
progressCurrent: document.getElementById("progress-current"),
|
||||
progressTotal: document.getElementById("progress-total"),
|
||||
milestonesContainer: document.getElementById("milestones"),
|
||||
resetBtn: document.getElementById("reset-btn"),
|
||||
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
|
||||
|
||||
@@ -223,6 +232,13 @@ function closeSidebar() {
|
||||
function toggleExpectedResult() {
|
||||
state.showExpected = !state.showExpected;
|
||||
|
||||
const engineState = lessonEngine.getCurrentState();
|
||||
track("expected_toggle", {
|
||||
show: state.showExpected,
|
||||
module: engineState.module?.id,
|
||||
lesson: engineState.lessonIndex
|
||||
});
|
||||
|
||||
if (state.showExpected) {
|
||||
elements.expectedOverlay.classList.add("visible");
|
||||
elements.showExpectedBtn.textContent = t("hideExpected");
|
||||
@@ -296,14 +312,53 @@ function showSuccessHint(message) {
|
||||
|
||||
// ================= PROGRESS DISPLAY =================
|
||||
|
||||
// Track last milestone to detect new achievements
|
||||
let lastMilestoneReached = 0;
|
||||
|
||||
function updateProgressDisplay() {
|
||||
const stats = lessonEngine.getProgressStats();
|
||||
elements.progressFill.style.width = `${stats.percentComplete}%`;
|
||||
elements.progressText.textContent = t("progressText", {
|
||||
percent: stats.percentComplete,
|
||||
completed: stats.totalCompleted,
|
||||
|
||||
// Update progress bar - shows progress towards next milestone
|
||||
// CSS variable scales gradient so only first X% of colors show
|
||||
const progressPercent = stats.progressToNext || 1;
|
||||
elements.progressFill.style.width = `${progressPercent}%`;
|
||||
elements.progressFill.style.setProperty('--progress-percent', progressPercent);
|
||||
|
||||
// Update progress current - show progress towards next milestone
|
||||
elements.progressCurrent.textContent = `${stats.totalCompleted}/${stats.nextMilestone}`;
|
||||
|
||||
// Update progress total - show total lessons
|
||||
elements.progressTotal.textContent = t("progressTotal", {
|
||||
total: stats.totalLessons
|
||||
});
|
||||
|
||||
// Update milestone indicators
|
||||
if (elements.milestonesContainer) {
|
||||
const milestoneEls = elements.milestonesContainer.querySelectorAll(".milestone");
|
||||
milestoneEls.forEach((el) => {
|
||||
const value = parseInt(el.dataset.value, 10);
|
||||
el.classList.remove("reached", "current", "next", "just-reached");
|
||||
|
||||
if (stats.milestonesReached.includes(value)) {
|
||||
el.classList.add("reached");
|
||||
// Check if this milestone was just reached
|
||||
if (value > lastMilestoneReached && value === stats.currentMilestone) {
|
||||
el.classList.add("just-reached");
|
||||
}
|
||||
} else if (value === stats.nextMilestone) {
|
||||
el.classList.add("next");
|
||||
}
|
||||
|
||||
if (value === stats.currentMilestone) {
|
||||
el.classList.add("current");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update last milestone for celebration detection
|
||||
if (stats.currentMilestone > lastMilestoneReached) {
|
||||
lastMilestoneReached = stats.currentMilestone;
|
||||
}
|
||||
}
|
||||
|
||||
// ================= USER SETTINGS =================
|
||||
@@ -395,6 +450,9 @@ function initializeModules() {
|
||||
// Use the new renderModuleList function with both callbacks
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
|
||||
|
||||
// Render footer lesson links (for all pages)
|
||||
renderFooterLessonLinks();
|
||||
|
||||
// Handle route (home, section, or lesson)
|
||||
handleRoute(false);
|
||||
|
||||
@@ -456,6 +514,13 @@ function selectLesson(moduleId, lessonIndex) {
|
||||
|
||||
loadCurrentLesson();
|
||||
|
||||
// Update section color coding (after loadCurrentLesson to ensure content is loaded first)
|
||||
const newState = lessonEngine.getCurrentState();
|
||||
updateSectionColor(getModuleSection(newState.module));
|
||||
|
||||
// Update nav highlight
|
||||
updateNavHighlight({ type: RouteType.LESSON, moduleId, lessonIndex });
|
||||
|
||||
// Close sidebar after selection on mobile
|
||||
if (window.innerWidth <= 768) {
|
||||
closeSidebar();
|
||||
@@ -478,6 +543,7 @@ function resetSuccessIndicators() {
|
||||
elements.previewWrapper?.classList.remove("matched");
|
||||
elements.previewWrapper?.classList.remove("completed-glow");
|
||||
elements.previewSection?.classList.remove("matched");
|
||||
elements.previewSection?.classList.remove("completed-glow");
|
||||
|
||||
// Remove completion badge if present
|
||||
const badge = document.querySelector(".completion-badge");
|
||||
@@ -507,6 +573,16 @@ function updateEditorForMode(mode) {
|
||||
label: "CSS Editor",
|
||||
cmMode: "css"
|
||||
},
|
||||
markdown: {
|
||||
placeholder: "# Heading\n\nWrite your **Markdown** here...",
|
||||
label: "Markdown Editor",
|
||||
cmMode: "markdown"
|
||||
},
|
||||
javascript: {
|
||||
placeholder: "// Write your JavaScript here...",
|
||||
label: "JavaScript Editor",
|
||||
cmMode: "javascript"
|
||||
},
|
||||
playground: {
|
||||
placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
|
||||
label: "HTML & CSS",
|
||||
@@ -540,10 +616,12 @@ function loadCurrentLesson() {
|
||||
elements.instructionsSection?.classList.add("hidden");
|
||||
elements.editorSection?.classList.add("playground-mode");
|
||||
elements.randomTemplateBtn?.classList.remove("hidden");
|
||||
elements.showExpectedBtn?.classList.add("hidden");
|
||||
} else {
|
||||
elements.instructionsSection?.classList.remove("hidden");
|
||||
elements.editorSection?.classList.remove("playground-mode");
|
||||
elements.randomTemplateBtn?.classList.add("hidden");
|
||||
elements.showExpectedBtn?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Add transition class for smooth content swap
|
||||
@@ -581,40 +659,59 @@ function loadCurrentLesson() {
|
||||
lesson
|
||||
);
|
||||
|
||||
// Render difficulty badge
|
||||
renderDifficultyBadge(elements.lessonTitleRow, lesson);
|
||||
|
||||
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
|
||||
// Pass codePrefix/codeSuffix as read-only zones for CSS mode
|
||||
if (codeEditor) {
|
||||
codeEditor.setValueAndClearHistory(engineState.userCode);
|
||||
const prefix = lesson.codePrefix || "";
|
||||
const suffix = lesson.codeSuffix || "";
|
||||
codeEditor.setValueAndClearHistory(engineState.userCode, prefix, suffix);
|
||||
}
|
||||
|
||||
// Update Run button text based on completion status
|
||||
if (engineState.isCompleted) {
|
||||
elements.runBtn.querySelector("span").textContent = t("rerun");
|
||||
|
||||
// Add completion badge if not present
|
||||
if (!document.querySelector(".completion-badge")) {
|
||||
// Add completion badge to difficulty-wrapper if not present
|
||||
const wrapper = document.querySelector(".difficulty-wrapper");
|
||||
if (wrapper && !wrapper.querySelector(".completion-badge")) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "completion-badge";
|
||||
badge.textContent = t("completed");
|
||||
elements.lessonTitleRow.appendChild(badge);
|
||||
wrapper.appendChild(badge);
|
||||
}
|
||||
|
||||
// Show gradient border for completed lessons
|
||||
// Show gradient border and glow for completed lessons
|
||||
elements.previewWrapper?.classList.add("completed-glow");
|
||||
elements.previewSection?.classList.add("completed-glow");
|
||||
} else {
|
||||
elements.runBtn.querySelector("span").textContent = t("run");
|
||||
|
||||
// Remove completion badge and border if exists
|
||||
// Remove completion badge if exists
|
||||
const badge = document.querySelector(".completion-badge");
|
||||
if (badge) badge.remove();
|
||||
elements.previewWrapper?.classList.remove("completed-glow");
|
||||
elements.previewSection?.classList.remove("completed-glow");
|
||||
}
|
||||
|
||||
// Update level indicator
|
||||
renderLevelIndicator(elements.levelIndicator, engineState.lessonIndex + 1, engineState.totalLessons);
|
||||
// Update level indicator (hide in playground mode)
|
||||
if (isPlayground) {
|
||||
elements.levelIndicator.classList.add("hidden");
|
||||
} else {
|
||||
elements.levelIndicator.classList.remove("hidden");
|
||||
renderLevelIndicator(elements.levelIndicator, engineState.lessonIndex + 1, engineState.totalLessons);
|
||||
}
|
||||
// Header pill shows module name + level (clickable link to return to lesson)
|
||||
if (elements.headerLevelPill && engineState.module) {
|
||||
const label = t("lessonLabel");
|
||||
elements.headerLevelPill.innerHTML = `<span class="header-module-name">${engineState.module.title}</span> <span class="header-level">${label} ${engineState.lessonIndex + 1} / ${engineState.totalLessons}</span>`;
|
||||
if (isPlayground) {
|
||||
// Playground: just show title, no lesson count
|
||||
elements.headerLevelPill.innerHTML = `<span class="header-module-name">${engineState.module.title}</span>`;
|
||||
} else {
|
||||
const label = t("lessonLabel");
|
||||
elements.headerLevelPill.innerHTML = `<span class="header-module-name">${engineState.module.title}</span> <span class="header-level">${label} ${engineState.lessonIndex + 1} / ${engineState.totalLessons}</span>`;
|
||||
}
|
||||
elements.headerLevelPill.href = `#${engineState.module.id}/${engineState.lessonIndex}`;
|
||||
}
|
||||
|
||||
@@ -677,12 +774,13 @@ function handleEditorChange(code) {
|
||||
|
||||
function updateNavigationButtons() {
|
||||
const engineState = lessonEngine.getCurrentState();
|
||||
const isPlayground = engineState.lesson?.mode === "playground";
|
||||
|
||||
// Update button states
|
||||
elements.prevBtn.disabled = !engineState.canGoPrev;
|
||||
elements.nextBtn.disabled = !engineState.canGoNext;
|
||||
|
||||
elements.nextBtn.disabled = isPlayground || !engineState.canGoNext;
|
||||
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
|
||||
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
|
||||
elements.nextBtn.classList.toggle("btn-disabled", isPlayground || !engineState.canGoNext);
|
||||
}
|
||||
|
||||
function nextLesson() {
|
||||
@@ -694,16 +792,21 @@ function nextLesson() {
|
||||
// Update URL
|
||||
updateHash(newState.module.id, newState.lessonIndex);
|
||||
|
||||
if (newState.module.id !== prevModuleId) {
|
||||
const moduleChanged = newState.module.id !== prevModuleId;
|
||||
if (moduleChanged) {
|
||||
updateModuleHighlight(newState.module.id);
|
||||
updateSectionColor(getModuleSection(newState.module));
|
||||
}
|
||||
loadCurrentLesson();
|
||||
if (moduleChanged) {
|
||||
updateSectionColor(getModuleSection(newState.module));
|
||||
updateNavHighlight({ type: RouteType.LESSON, moduleId: newState.module.id, lessonIndex: newState.lessonIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prevLesson() {
|
||||
const prevModuleId = lessonEngine.getCurrentState().module?.id;
|
||||
const engineState = lessonEngine.getCurrentState();
|
||||
const prevModuleId = engineState.module?.id;
|
||||
const success = lessonEngine.previousLesson();
|
||||
if (success) {
|
||||
const newState = lessonEngine.getCurrentState();
|
||||
@@ -711,11 +814,15 @@ function prevLesson() {
|
||||
// Update URL
|
||||
updateHash(newState.module.id, newState.lessonIndex);
|
||||
|
||||
if (newState.module.id !== prevModuleId) {
|
||||
const moduleChanged = newState.module.id !== prevModuleId;
|
||||
if (moduleChanged) {
|
||||
updateModuleHighlight(newState.module.id);
|
||||
updateSectionColor(getModuleSection(newState.module));
|
||||
}
|
||||
loadCurrentLesson();
|
||||
if (moduleChanged) {
|
||||
updateSectionColor(getModuleSection(newState.module));
|
||||
updateNavHighlight({ type: RouteType.LESSON, moduleId: newState.module.id, lessonIndex: newState.lessonIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,10 +882,12 @@ function loadRandomTemplate() {
|
||||
}
|
||||
|
||||
function runCode() {
|
||||
const userCode = codeEditor ? codeEditor.getValue() : "";
|
||||
const userCode = codeEditor ? codeEditor.getEditableValue() : "";
|
||||
const engineState = lessonEngine.getCurrentState();
|
||||
const isPlayground = engineState.lesson?.mode === "playground";
|
||||
|
||||
track("run_code", { module: engineState.module?.id, lesson: engineState.lessonIndex, playground: isPlayground });
|
||||
|
||||
// Rotate the Run button icon
|
||||
const runButtonImg = document.querySelector("#run-btn img");
|
||||
if (runButtonImg) {
|
||||
@@ -845,8 +954,9 @@ function runCode() {
|
||||
state.animationTimeout = setTimeout(() => {
|
||||
elements.previewWrapper?.classList.remove("matched");
|
||||
elements.previewSection?.classList.remove("matched");
|
||||
// Keep the gradient border visible after animation
|
||||
// Keep the gradient border and glow visible after animation
|
||||
elements.previewWrapper?.classList.add("completed-glow");
|
||||
elements.previewSection?.classList.add("completed-glow");
|
||||
state.animationTimeout = null;
|
||||
}, 3500);
|
||||
|
||||
@@ -1309,6 +1419,143 @@ const sectionContent = {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
markdown: `
|
||||
<div class="section-overview">
|
||||
<p><strong>Markdown</strong> is a lightweight markup language created by John Gruber in 2004. It lets you write formatted text using plain text syntax that's easy to read and write. Markdown is used everywhere—from GitHub READMEs to documentation, note-taking apps, and content management systems.</p>
|
||||
<p>The beauty of Markdown is its simplicity: <code># Heading</code> creates a heading, <code>**bold**</code> makes text bold, and <code>[link](url)</code> creates a link. No complex HTML tags needed. Markdown files can be converted to HTML, PDF, or many other formats.</p>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>Headings & Structure</h2>
|
||||
<p>Create document structure with headings using <code>#</code> symbols. One <code>#</code> for h1, two <code>##</code> for h2, up to six levels. This creates a clear hierarchy in your documents.</p>
|
||||
<p>
|
||||
<a href="#markdown-basics/0" class="topic-link">Practice headings →</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code># Main Title
|
||||
## Section
|
||||
### Subsection
|
||||
#### Detail</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>Text Formatting</h2>
|
||||
<p>Emphasize text with <code>**bold**</code> or <code>*italic*</code>. Combine them with <code>***bold italic***</code>. Use backticks for <code>\`inline code\`</code> to highlight commands or code snippets in your text.</p>
|
||||
<p>
|
||||
<a href="#markdown-basics/2" class="topic-link">Practice formatting →</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code>This is **bold** text.
|
||||
This is *italic* text.
|
||||
This is \`inline code\`.</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>Lists</h2>
|
||||
<p>Create bullet lists with <code>-</code>, <code>*</code>, or <code>+</code>. Numbered lists use <code>1.</code>, <code>2.</code>, etc. Indent items with spaces to create nested lists for complex outlines.</p>
|
||||
<p>
|
||||
<a href="#markdown-basics/4" class="topic-link">Practice lists →</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code>- First item
|
||||
- Second item
|
||||
- Nested item
|
||||
|
||||
1. Step one
|
||||
2. Step two
|
||||
3. Step three</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>Links & Images</h2>
|
||||
<p>Create links with <code>[text](url)</code> syntax. Images use the same format with an exclamation mark: <code></code>. The alt text describes the image for accessibility.</p>
|
||||
<p>
|
||||
<a href="#markdown-basics/6" class="topic-link">Practice links →</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code>[Visit Google](https://google.com)
|
||||
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
javascript: `
|
||||
<div class="section-overview">
|
||||
<p><strong>JavaScript</strong> is the programming language of the web. It makes pages interactive—responding to clicks, updating content, and manipulating the DOM (Document Object Model). Every modern website uses JavaScript to create dynamic user experiences.</p>
|
||||
<p>Start with the fundamentals: declaring variables with <code>const</code> and <code>let</code>, selecting elements with <code>querySelector</code>, changing content and styles, and responding to user events like clicks.</p>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>Variables</h2>
|
||||
<p>Store values using <code>const</code> (cannot be reassigned) and <code>let</code> (can be updated). Use template literals with backticks to build dynamic strings with embedded expressions.</p>
|
||||
<p>
|
||||
<a href="#js-variables/0" class="topic-link">Practice variables</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code>const name = "Ada";
|
||||
let score = 0;
|
||||
const msg = \`Hi, \${name}!\`;</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>DOM Manipulation</h2>
|
||||
<p>Use <code>document.querySelector()</code> to find elements, <code>textContent</code> to change text, and <code>style</code> to modify CSS properties directly from JavaScript.</p>
|
||||
<p>
|
||||
<a href="#js-dom/0" class="topic-link">Practice DOM</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code>const el = document.querySelector("#box");
|
||||
el.textContent = "Hello!";
|
||||
el.style.backgroundColor = "gold";</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topic-row">
|
||||
<div class="topic-text">
|
||||
<h2>Events</h2>
|
||||
<p>Use <code>addEventListener</code> to respond to user interactions. Handle clicks, toggle classes, and build interactive features like counters and toggles.</p>
|
||||
<p>
|
||||
<a href="#js-events/0" class="topic-link">Practice events</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="topic-code">
|
||||
<div class="code-block">
|
||||
<pre><code>btn.addEventListener("click", () => {
|
||||
count++;
|
||||
out.textContent = count;
|
||||
});</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -1825,6 +2072,105 @@ const referenceContent = {
|
||||
</section>
|
||||
|
||||
<p class="ref-see-also">Learn: <a href="#html">HTML Section</a> | Style with: <a href="#reference/css">CSS Properties</a></p>
|
||||
`,
|
||||
|
||||
markdown: `
|
||||
<h1>Markdown Syntax Reference</h1>
|
||||
<p class="ref-intro">A quick guide to Markdown syntax for formatting text documents. Markdown is used in GitHub, documentation, and note-taking apps.</p>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Text Formatting</h2>
|
||||
<table class="ref-table">
|
||||
<thead><tr><th>Syntax</th><th>Result</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>**bold**</code></td><td><strong>bold</strong></td><td>Or use __bold__</td></tr>
|
||||
<tr><td><code>*italic*</code></td><td><em>italic</em></td><td>Or use _italic_</td></tr>
|
||||
<tr><td><code>***bold italic***</code></td><td><strong><em>bold italic</em></strong></td><td>Combine both</td></tr>
|
||||
<tr><td><code>~~strikethrough~~</code></td><td><s>strikethrough</s></td><td>GFM extension</td></tr>
|
||||
<tr><td><code>\`inline code\`</code></td><td><code>inline code</code></td><td>Monospace font</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Headings</h2>
|
||||
<table class="ref-table">
|
||||
<thead><tr><th>Syntax</th><th>Level</th><th>Usage</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code># Heading 1</code></td><td>h1</td><td>Document title</td></tr>
|
||||
<tr><td><code>## Heading 2</code></td><td>h2</td><td>Main sections</td></tr>
|
||||
<tr><td><code>### Heading 3</code></td><td>h3</td><td>Subsections</td></tr>
|
||||
<tr><td><code>#### Heading 4</code></td><td>h4</td><td>Minor sections</td></tr>
|
||||
<tr><td><code>##### Heading 5</code></td><td>h5</td><td>Rarely used</td></tr>
|
||||
<tr><td><code>###### Heading 6</code></td><td>h6</td><td>Smallest heading</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Lists</h2>
|
||||
<table class="ref-table">
|
||||
<thead><tr><th>Syntax</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>- Item</code></td><td>Unordered</td><td>Or use * or +</td></tr>
|
||||
<tr><td><code>1. Item</code></td><td>Ordered</td><td>Numbers auto-increment</td></tr>
|
||||
<tr><td><code> - Nested</code></td><td>Nested list</td><td>2-space indent</td></tr>
|
||||
<tr><td><code>- [x] Task</code></td><td>Task list</td><td>GFM extension</td></tr>
|
||||
<tr><td><code>- [ ] Task</code></td><td>Unchecked task</td><td>Interactive checkboxes</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Links & Images</h2>
|
||||
<table class="ref-table">
|
||||
<thead><tr><th>Syntax</th><th>Purpose</th><th>Example</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>[text](url)</code></td><td>Inline link</td><td>[Google](https://google.com)</td></tr>
|
||||
<tr><td><code>[text](url "title")</code></td><td>Link with tooltip</td><td>Hover text</td></tr>
|
||||
<tr><td><code></code></td><td>Image</td><td>Alt text for accessibility</td></tr>
|
||||
<tr><td><code><url></code></td><td>Auto-link</td><td>URLs become clickable</td></tr>
|
||||
<tr><td><code>[ref]: url</code></td><td>Reference link</td><td>Define at doc bottom</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Code Blocks</h2>
|
||||
<table class="ref-table">
|
||||
<thead><tr><th>Syntax</th><th>Purpose</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>\`\`\`</code></td><td>Fenced code</td><td>3 backticks or tildes</td></tr>
|
||||
<tr><td><code>\`\`\`js</code></td><td>Syntax highlight</td><td>Add language identifier</td></tr>
|
||||
<tr><td><code> code</code></td><td>Indented code</td><td>4-space indent</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Block Elements</h2>
|
||||
<table class="ref-table">
|
||||
<thead><tr><th>Syntax</th><th>Element</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>> Quote</code></td><td>Blockquote</td><td>Nest with >></td></tr>
|
||||
<tr><td><code>---</code></td><td>Horizontal rule</td><td>Or *** or ___</td></tr>
|
||||
<tr><td><code>| A | B |</code></td><td>Table</td><td>GFM extension</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="ref-section">
|
||||
<h2>Tables (GFM)</h2>
|
||||
<div class="ref-example">
|
||||
<pre><code>| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| Cell 3 | Cell 4 |</code></pre>
|
||||
</div>
|
||||
<p>Use colons for alignment: <code>:---</code> (left), <code>:---:</code> (center), <code>---:</code> (right)</p>
|
||||
</section>
|
||||
|
||||
<p class="ref-see-also">Learn: <a href="#markdown">Markdown Section</a> | Also try: <a href="#html">HTML Elements</a></p>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -1852,7 +2198,7 @@ function stripHtml(html) {
|
||||
* Update page meta tags based on current route for SEO
|
||||
*/
|
||||
function updatePageMeta(route) {
|
||||
const defaultTitle = "Code Crispies - Learn HTML & CSS Interactively | Free Coding Practice";
|
||||
const defaultTitle = "CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice";
|
||||
const defaultDesc =
|
||||
"Master HTML, CSS, and Tailwind through hands-on coding exercises. Free, open-source learning platform with instant feedback. No account required.";
|
||||
|
||||
@@ -1870,9 +2216,9 @@ function updatePageMeta(route) {
|
||||
break;
|
||||
|
||||
case RouteType.SECTION: {
|
||||
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS" };
|
||||
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS", markdown: "Markdown" };
|
||||
const sectionName = sectionNames[route.sectionId] || route.sectionId;
|
||||
title = `${sectionName} Lessons - Code Crispies | Learn ${sectionName}`;
|
||||
title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`;
|
||||
description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`;
|
||||
break;
|
||||
}
|
||||
@@ -1881,7 +2227,7 @@ function updatePageMeta(route) {
|
||||
const module = lessonEngine.modules.find((m) => m.id === route.moduleId);
|
||||
const lesson = module?.lessons[route.lessonIndex];
|
||||
if (module && lesson) {
|
||||
title = `${lesson.title} - ${module.title} | Code Crispies`;
|
||||
title = `${lesson.title} - ${module.title} | CODE CRISPIES`;
|
||||
const lessonDesc = stripHtml(lesson.description || lesson.task);
|
||||
description = lessonDesc.length > 155 ? lessonDesc.slice(0, 152) + "..." : lessonDesc || defaultDesc;
|
||||
}
|
||||
@@ -1894,10 +2240,11 @@ function updatePageMeta(route) {
|
||||
selectors: "CSS Selectors",
|
||||
flexbox: "Flexbox",
|
||||
grid: "CSS Grid",
|
||||
html: "HTML Elements"
|
||||
html: "HTML Elements",
|
||||
markdown: "Markdown Syntax"
|
||||
};
|
||||
const refName = refNames[route.refId] || "Reference";
|
||||
title = `${refName} Reference - Code Crispies`;
|
||||
title = `${refName} Reference - CODE CRISPIES`;
|
||||
description = `Quick reference guide for ${refName}. Syntax, examples, and common patterns for web development.`;
|
||||
break;
|
||||
}
|
||||
@@ -1913,13 +2260,13 @@ function updatePageMeta(route) {
|
||||
// Update Open Graph tags
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
const ogDesc = document.querySelector('meta[property="og:description"]');
|
||||
if (ogTitle) ogTitle.setAttribute("content", title.replace(" | Code Crispies", "").replace(" - Code Crispies", ""));
|
||||
if (ogTitle) ogTitle.setAttribute("content", title.replace(" | CODE CRISPIES", "").replace(" - CODE CRISPIES", ""));
|
||||
if (ogDesc) ogDesc.setAttribute("content", description);
|
||||
|
||||
// Update Twitter tags
|
||||
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
|
||||
const twitterDesc = document.querySelector('meta[name="twitter:description"]');
|
||||
if (twitterTitle) twitterTitle.setAttribute("content", title.replace(" | Code Crispies", "").replace(" - Code Crispies", ""));
|
||||
if (twitterTitle) twitterTitle.setAttribute("content", title.replace(" | CODE CRISPIES", "").replace(" - CODE CRISPIES", ""));
|
||||
if (twitterDesc) twitterDesc.setAttribute("content", description);
|
||||
}
|
||||
|
||||
@@ -1994,6 +2341,11 @@ function updateSectionColor(sectionId) {
|
||||
} else {
|
||||
document.body.removeAttribute("data-section");
|
||||
}
|
||||
|
||||
// Update code editor theme for section
|
||||
if (codeEditor) {
|
||||
codeEditor.setSection(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2002,7 +2354,6 @@ function updateSectionColor(sectionId) {
|
||||
function showLandingPage() {
|
||||
hideAllPages();
|
||||
elements.landingPage?.classList.remove("hidden");
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Reset section color on landing page
|
||||
updateSectionColor(null);
|
||||
@@ -2012,16 +2363,17 @@ function showLandingPage() {
|
||||
|
||||
// Render footer lesson links
|
||||
renderFooterLessonLinks();
|
||||
|
||||
// Scroll to top after content is rendered
|
||||
requestAnimationFrame(() => window.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render module links in the landing page footer, grouped by section
|
||||
*/
|
||||
function renderFooterLessonLinks() {
|
||||
if (!elements.footerLessonLinks) return;
|
||||
|
||||
const modules = lessonEngine.modules || [];
|
||||
const sectionGroups = { css: [], html: [] };
|
||||
const sectionGroups = { css: [], html: [], markdown: [], javascript: [] };
|
||||
|
||||
modules.forEach((module) => {
|
||||
if (module.excludeFromProgress) return;
|
||||
@@ -2042,14 +2394,23 @@ function renderFooterLessonLinks() {
|
||||
html += "</div>";
|
||||
});
|
||||
|
||||
elements.footerLessonLinks.innerHTML = html;
|
||||
// Update all footer lesson links
|
||||
if (elements.footerLessonLinks) {
|
||||
elements.footerLessonLinks.innerHTML = html;
|
||||
}
|
||||
if (elements.refFooterLessonLinks) {
|
||||
elements.refFooterLessonLinks.innerHTML = html;
|
||||
}
|
||||
if (elements.sectionFooterLessonLinks) {
|
||||
elements.sectionFooterLessonLinks.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress indicators on landing page
|
||||
*/
|
||||
function updateLandingProgress() {
|
||||
["css", "html", "tailwind"].forEach((sectionId) => {
|
||||
["css", "html", "markdown", "javascript"].forEach((sectionId) => { // tailwind temporarily disabled
|
||||
const progressEl = document.getElementById(`${sectionId}-progress`);
|
||||
if (progressEl) {
|
||||
const sectionModules = getModulesBySection(lessonEngine.modules, sectionId);
|
||||
@@ -2077,7 +2438,6 @@ function updateLandingProgress() {
|
||||
function showSectionPage(sectionId) {
|
||||
hideAllPages();
|
||||
elements.sectionPage?.classList.remove("hidden");
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Update section color
|
||||
updateSectionColor(sectionId);
|
||||
@@ -2120,6 +2480,9 @@ function showSectionPage(sectionId) {
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
if (elements.sectionProgressFill) elements.sectionProgressFill.style.width = `${percent}%`;
|
||||
if (elements.sectionProgressText) elements.sectionProgressText.textContent = `${completed} of ${total} lessons complete`;
|
||||
|
||||
// Scroll to top after content is rendered
|
||||
requestAnimationFrame(() => window.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2128,13 +2491,12 @@ function showSectionPage(sectionId) {
|
||||
function showReferencePage(refId) {
|
||||
hideAllPages();
|
||||
elements.referencePage?.classList.remove("hidden");
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Default to CSS if no refId
|
||||
const activeRef = refId || "css";
|
||||
|
||||
// Map reference to section for color coding
|
||||
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html" };
|
||||
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html", markdown: "markdown" };
|
||||
updateSectionColor(refToSection[activeRef] || "css");
|
||||
|
||||
// Track reference page view
|
||||
@@ -2156,6 +2518,9 @@ function showReferencePage(refId) {
|
||||
} else if (elements.referenceBody) {
|
||||
elements.referenceBody.innerHTML = `<p>Reference for "${activeRef}" coming soon...</p>`;
|
||||
}
|
||||
|
||||
// Scroll to top after content is rendered
|
||||
requestAnimationFrame(() => window.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2337,6 +2702,11 @@ function init() {
|
||||
// Initialize i18n before anything else
|
||||
initI18n();
|
||||
|
||||
// Set dynamic year in footer
|
||||
document.querySelectorAll(".current-year").forEach((el) => {
|
||||
el.textContent = new Date().getFullYear();
|
||||
});
|
||||
|
||||
loadUserSettings();
|
||||
|
||||
// Restore cached lesson content immediately to avoid "Loading..." flash
|
||||
@@ -2348,22 +2718,34 @@ function init() {
|
||||
// Set timeout to show fallback if loading takes too long
|
||||
loadingTimeout = setTimeout(showLoadingFallback, 3000);
|
||||
|
||||
// Load modules after editor is ready
|
||||
initializeModules();
|
||||
// Handle OAuth callback FIRST (tokens are in URL hash, must run before router)
|
||||
handleOAuthCallback().then(() => {
|
||||
// Load modules (this also calls handleRoute inside)
|
||||
initializeModules();
|
||||
|
||||
// Initialize URL router for shareable links
|
||||
initRouter();
|
||||
// Initialize URL router for browser back/forward
|
||||
initRouter();
|
||||
|
||||
// Initialize authentication
|
||||
initAuth(lessonEngine);
|
||||
});
|
||||
|
||||
// Sidebar controls
|
||||
elements.menuBtn.addEventListener("click", openSidebar);
|
||||
elements.closeSidebar.addEventListener("click", closeSidebar);
|
||||
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
|
||||
|
||||
// Sidebar nav links (mobile) - close sidebar on click
|
||||
document.querySelectorAll(".sidebar-nav-link").forEach((link) => {
|
||||
link.addEventListener("click", closeSidebar);
|
||||
});
|
||||
|
||||
// Logo click - navigate to home landing
|
||||
elements.logoLink.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
navigateTo("");
|
||||
showLandingPage();
|
||||
track("logo_click");
|
||||
});
|
||||
|
||||
// Language select
|
||||
@@ -2416,10 +2798,42 @@ function init() {
|
||||
});
|
||||
elements.copyUrlBtn.addEventListener("click", copyShareUrl);
|
||||
|
||||
// Legal dialogs (Privacy & Imprint)
|
||||
const privacyDialog = document.getElementById("privacy-dialog");
|
||||
const imprintDialog = document.getElementById("imprint-dialog");
|
||||
|
||||
document.querySelectorAll(".privacy-link").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
privacyDialog?.showModal();
|
||||
track("privacy_open");
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".imprint-link").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
imprintDialog?.showModal();
|
||||
track("imprint_open");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector(".privacy-dialog-close")?.addEventListener("click", () => {
|
||||
privacyDialog?.close();
|
||||
});
|
||||
document.querySelector(".imprint-dialog-close")?.addEventListener("click", () => {
|
||||
imprintDialog?.close();
|
||||
});
|
||||
|
||||
privacyDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === privacyDialog) privacyDialog.close();
|
||||
});
|
||||
imprintDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === imprintDialog) imprintDialog.close();
|
||||
});
|
||||
|
||||
// Settings
|
||||
elements.disableFeedbackToggle.addEventListener("change", (e) => {
|
||||
state.userSettings.disableFeedbackErrors = !e.target.checked;
|
||||
saveUserSettings();
|
||||
track("setting_change", { setting: "feedback_errors", enabled: e.target.checked });
|
||||
});
|
||||
|
||||
// Click on editor content to focus CodeMirror
|
||||
@@ -2427,6 +2841,46 @@ function init() {
|
||||
if (codeEditor) codeEditor.focus();
|
||||
});
|
||||
|
||||
// Track clicks on "Coming Soon" disabled topic links
|
||||
document.addEventListener("click", (e) => {
|
||||
const disabledLink = e.target.closest(".topic-link-disabled");
|
||||
if (disabledLink) {
|
||||
const topicText = disabledLink.textContent.replace("Coming Soon", "").trim();
|
||||
track("coming_soon_click", { topic: topicText });
|
||||
}
|
||||
});
|
||||
|
||||
// Track external link clicks
|
||||
document.addEventListener("click", (e) => {
|
||||
const link = e.target.closest('a[target="_blank"]');
|
||||
if (link) {
|
||||
track("external_link", { url: link.href, text: link.textContent.trim() });
|
||||
}
|
||||
});
|
||||
|
||||
// Track header nav link clicks (CSS, HTML, Tailwind)
|
||||
document.querySelectorAll(".nav-link[data-section]").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
track("header_nav_click", { section: link.dataset.section });
|
||||
});
|
||||
});
|
||||
|
||||
// Track footer link clicks
|
||||
document.addEventListener("click", (e) => {
|
||||
const footerLink = e.target.closest(".landing-footer a, .section-footer a, .reference-footer a");
|
||||
if (footerLink && !footerLink.target) {
|
||||
track("footer_link", { href: footerLink.getAttribute("href"), text: footerLink.textContent.trim() });
|
||||
}
|
||||
});
|
||||
|
||||
// Track practice/reference cross-links
|
||||
document.addEventListener("click", (e) => {
|
||||
const refLink = e.target.closest(".ref-see-also a");
|
||||
if (refLink) {
|
||||
track("practice_link", { href: refLink.getAttribute("href"), text: refLink.textContent.trim() });
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Ctrl+Enter to run code
|
||||
@@ -2454,6 +2908,27 @@ function init() {
|
||||
track("support_click", { location: "landing" });
|
||||
}
|
||||
});
|
||||
|
||||
// Newsletter form submission
|
||||
const newsletterForm = document.getElementById("newsletter-form");
|
||||
const newsletterThanks = document.getElementById("newsletter-thanks");
|
||||
newsletterForm?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const emailInput = document.getElementById("newsletter-email");
|
||||
const email = emailInput?.value;
|
||||
if (email) {
|
||||
// Import newsletter helper dynamically to avoid loading Supabase if not needed
|
||||
try {
|
||||
const { newsletter } = await import("./supabase.js");
|
||||
await newsletter.subscribe(email);
|
||||
} catch (err) {
|
||||
console.error("Newsletter subscription error:", err);
|
||||
}
|
||||
track("newsletter_signup", { email: email });
|
||||
newsletterForm.classList.add("hidden");
|
||||
newsletterThanks?.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start the application
|
||||
|
||||
526
src/auth.js
Normal file
526
src/auth.js
Normal file
@@ -0,0 +1,526 @@
|
||||
import { t, applyTranslations } from "./i18n.js";
|
||||
|
||||
// Analytics tracking helper
|
||||
function track(eventName, eventData = {}) {
|
||||
if (typeof umami !== "undefined" && umami.track) {
|
||||
umami.track(eventName, eventData);
|
||||
}
|
||||
}
|
||||
|
||||
let currentUser = null;
|
||||
let oauthHandled = false;
|
||||
let lessonEngineRef = null;
|
||||
let authModule = null;
|
||||
let progressModule = null;
|
||||
let supabaseAvailable = false;
|
||||
|
||||
/**
|
||||
* Check for OAuth callback tokens in URL hash BEFORE router runs.
|
||||
* Call this before initializing the router.
|
||||
* @returns {Promise<boolean>} true if OAuth callback was detected and handled
|
||||
*/
|
||||
export async function handleOAuthCallback() {
|
||||
const hash = window.location.hash;
|
||||
|
||||
// Check if hash contains OAuth tokens (access_token, error, etc.)
|
||||
if (!hash.includes("access_token") && !hash.includes("error_description") && !hash.includes("refresh_token")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseModule = await import("./supabase.js");
|
||||
if (!supabaseModule.isConfigured) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse tokens from hash
|
||||
const params = new URLSearchParams(hash.substring(1));
|
||||
const accessToken = params.get("access_token");
|
||||
const refreshToken = params.get("refresh_token");
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
// Explicitly set the session with tokens from URL
|
||||
const { data, error } = await supabaseModule.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (!error && data?.session) {
|
||||
oauthHandled = true;
|
||||
const provider = data.session.user?.app_metadata?.provider || "oauth";
|
||||
track("auth_login", { method: provider });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the original route (saved before OAuth redirect)
|
||||
const savedRoute = localStorage.getItem("codeCrispies.oauthReturnRoute");
|
||||
if (savedRoute) {
|
||||
localStorage.removeItem("codeCrispies.oauthReturnRoute");
|
||||
window.history.replaceState(null, "", window.location.pathname + savedRoute);
|
||||
} else {
|
||||
// No saved route - just clear the hash
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[Auth] OAuth callback handling failed:", e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the auth system
|
||||
* @param {Object} engine - The LessonEngine instance
|
||||
*/
|
||||
export async function initAuth(engine) {
|
||||
lessonEngineRef = engine;
|
||||
|
||||
// Try to load Supabase - if not configured, auth is disabled
|
||||
try {
|
||||
const supabaseModule = await import("./supabase.js");
|
||||
|
||||
// Check if Supabase is configured via environment variables
|
||||
if (!supabaseModule.isConfigured) {
|
||||
console.log("Supabase not configured - auth disabled");
|
||||
hideAuthUI();
|
||||
return;
|
||||
}
|
||||
|
||||
authModule = supabaseModule.auth;
|
||||
progressModule = supabaseModule.progressDB;
|
||||
supabaseAvailable = true;
|
||||
} catch (e) {
|
||||
console.log("Supabase not available - auth disabled:", e.message);
|
||||
hideAuthUI();
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for auth changes
|
||||
authModule.onAuthStateChange((event, session) => {
|
||||
if (event === "SIGNED_IN" && session?.user) {
|
||||
handleLogin(session.user);
|
||||
} else if (event === "SIGNED_OUT") {
|
||||
handleLogout();
|
||||
}
|
||||
});
|
||||
|
||||
// Check initial session
|
||||
try {
|
||||
const { data } = await authModule.getSession();
|
||||
if (data?.session?.user) handleLogin(data.session.user);
|
||||
} catch (e) {
|
||||
// Session check failed - continue without auth
|
||||
}
|
||||
|
||||
// Attach form handlers
|
||||
setupAuthForms();
|
||||
}
|
||||
|
||||
function hideAuthUI() {
|
||||
document.getElementById("auth-trigger-header")?.classList.add("hidden");
|
||||
document.querySelector(".sidebar-auth-box")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function handleLogin(user) {
|
||||
currentUser = user;
|
||||
updateAuthUI(user);
|
||||
|
||||
if (!progressModule) return;
|
||||
|
||||
// Load cloud progress
|
||||
const { data } = await progressModule.load(user.id);
|
||||
|
||||
if (data) {
|
||||
// Merge with localStorage (cloud wins for conflicts)
|
||||
mergeProgress(data);
|
||||
} else {
|
||||
// First login: upload localStorage to cloud
|
||||
await syncToCloud();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
currentUser = null;
|
||||
updateAuthUI(null);
|
||||
// Keep localStorage progress, just disconnect from cloud
|
||||
}
|
||||
|
||||
function updateAuthUI(user) {
|
||||
// Header elements
|
||||
const authTriggerHeader = document.getElementById("auth-trigger-header");
|
||||
const userEmailHeader = document.getElementById("user-email-header");
|
||||
|
||||
// Sidebar elements
|
||||
const authTriggerSidebar = document.getElementById("auth-trigger-sidebar");
|
||||
const authTriggerMobile = document.getElementById("auth-trigger-mobile");
|
||||
const userMenuSidebar = document.getElementById("user-menu-sidebar");
|
||||
const userEmailSidebar = document.getElementById("user-email-sidebar");
|
||||
const sidebarHint = document.querySelector(".sidebar-auth-hint");
|
||||
|
||||
if (user) {
|
||||
authTriggerHeader?.classList.add("hidden");
|
||||
userEmailHeader?.classList.remove("hidden");
|
||||
authTriggerSidebar?.classList.add("hidden");
|
||||
authTriggerMobile?.classList.add("hidden");
|
||||
userMenuSidebar?.classList.remove("hidden");
|
||||
sidebarHint?.classList.add("hidden");
|
||||
if (userEmailHeader) userEmailHeader.textContent = user.email;
|
||||
if (userEmailSidebar) userEmailSidebar.textContent = user.email;
|
||||
} else {
|
||||
authTriggerHeader?.classList.remove("hidden");
|
||||
userEmailHeader?.classList.add("hidden");
|
||||
authTriggerSidebar?.classList.remove("hidden");
|
||||
authTriggerMobile?.classList.remove("hidden");
|
||||
userMenuSidebar?.classList.add("hidden");
|
||||
sidebarHint?.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncToCloud() {
|
||||
if (!currentUser || !progressModule) return;
|
||||
|
||||
const progress = JSON.parse(
|
||||
localStorage.getItem("codeCrispies.progress") || "{}"
|
||||
);
|
||||
const userCodeEntries = JSON.parse(
|
||||
localStorage.getItem("codeCrispies.userCode") || "[]"
|
||||
);
|
||||
const userCode = Object.fromEntries(userCodeEntries);
|
||||
const settings = JSON.parse(
|
||||
localStorage.getItem("codeCrispies.settings") || "{}"
|
||||
);
|
||||
const language = localStorage.getItem("codeCrispies.language") || "en";
|
||||
|
||||
await progressModule.save(currentUser.id, progress, userCode, settings, language);
|
||||
}
|
||||
|
||||
function mergeProgress(cloudData) {
|
||||
// Update localStorage with cloud data
|
||||
localStorage.setItem(
|
||||
"codeCrispies.progress",
|
||||
JSON.stringify(cloudData.progress)
|
||||
);
|
||||
localStorage.setItem(
|
||||
"codeCrispies.userCode",
|
||||
JSON.stringify(Object.entries(cloudData.user_code))
|
||||
);
|
||||
localStorage.setItem(
|
||||
"codeCrispies.settings",
|
||||
JSON.stringify(cloudData.settings)
|
||||
);
|
||||
localStorage.setItem("codeCrispies.language", cloudData.language);
|
||||
|
||||
// Reload engine state
|
||||
if (lessonEngineRef) {
|
||||
lessonEngineRef.loadUserProgress();
|
||||
lessonEngineRef.loadUserCodeFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return supabaseAvailable && currentUser !== null;
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
// Debounce utility
|
||||
function debounce(fn, delay) {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
// Export debounced sync for use by LessonEngine
|
||||
export const debouncedSyncToCloud = debounce(() => syncToCloud(), 2000);
|
||||
|
||||
function setupAuthForms() {
|
||||
const authDialog = document.getElementById("auth-dialog");
|
||||
const loginForm = document.getElementById("login-form");
|
||||
const signupForm = document.getElementById("signup-form");
|
||||
const resetForm = document.getElementById("reset-form");
|
||||
|
||||
// Form submissions
|
||||
loginForm?.addEventListener("submit", handleLoginSubmit);
|
||||
signupForm?.addEventListener("submit", handleSignupSubmit);
|
||||
resetForm?.addEventListener("submit", handleResetSubmit);
|
||||
|
||||
// Form switchers
|
||||
document
|
||||
.getElementById("show-signup")
|
||||
?.addEventListener("click", () => switchForm("signup"));
|
||||
document
|
||||
.getElementById("show-login")
|
||||
?.addEventListener("click", () => switchForm("login"));
|
||||
document
|
||||
.getElementById("show-reset")
|
||||
?.addEventListener("click", () => switchForm("reset"));
|
||||
|
||||
// Dialog triggers (header, sidebar, and mobile)
|
||||
document
|
||||
.getElementById("auth-trigger-header")
|
||||
?.addEventListener("click", () => {
|
||||
authDialog?.showModal();
|
||||
});
|
||||
document
|
||||
.getElementById("auth-trigger-sidebar")
|
||||
?.addEventListener("click", () => {
|
||||
authDialog?.showModal();
|
||||
});
|
||||
document
|
||||
.getElementById("auth-trigger-mobile")
|
||||
?.addEventListener("click", () => {
|
||||
authDialog?.showModal();
|
||||
});
|
||||
|
||||
// Logout button (sidebar only)
|
||||
document
|
||||
.getElementById("logout-btn-sidebar")
|
||||
?.addEventListener("click", async () => {
|
||||
await authModule?.signOut();
|
||||
track("auth_logout");
|
||||
});
|
||||
|
||||
// Delete account button and dialog
|
||||
const deleteDialog = document.getElementById("delete-account-dialog");
|
||||
|
||||
document
|
||||
.getElementById("delete-account-btn")
|
||||
?.addEventListener("click", () => {
|
||||
deleteDialog?.showModal();
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("cancel-delete")
|
||||
?.addEventListener("click", () => {
|
||||
deleteDialog?.close();
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("delete-dialog-close")
|
||||
?.addEventListener("click", () => {
|
||||
deleteDialog?.close();
|
||||
});
|
||||
|
||||
deleteDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === deleteDialog) deleteDialog.close();
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("confirm-delete")
|
||||
?.addEventListener("click", async () => {
|
||||
const errorEl = document.getElementById("delete-account-error");
|
||||
const confirmBtn = document.getElementById("confirm-delete");
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
const { error } = await authModule.deleteAccount();
|
||||
|
||||
if (error) {
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
confirmBtn.disabled = false;
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
deleteDialog.close();
|
||||
track("auth_delete_account");
|
||||
// Sign out and clear local state
|
||||
await authModule.signOut();
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth buttons - save current route before redirect
|
||||
document.getElementById("google-login")?.addEventListener("click", async () => {
|
||||
// Save current route to restore after OAuth
|
||||
const currentHash = window.location.hash;
|
||||
if (currentHash && !currentHash.includes("access_token")) {
|
||||
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
|
||||
}
|
||||
const { error } = await authModule?.signInWithGoogle() ?? { error: null };
|
||||
if (error) {
|
||||
showOAuthError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("github-login")?.addEventListener("click", async () => {
|
||||
// Save current route to restore after OAuth
|
||||
const currentHash = window.location.hash;
|
||||
if (currentHash && !currentHash.includes("access_token")) {
|
||||
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
|
||||
}
|
||||
const { error } = await authModule?.signInWithGitHub() ?? { error: null };
|
||||
if (error) {
|
||||
showOAuthError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Close dialog on backdrop click
|
||||
authDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === authDialog) authDialog.close();
|
||||
});
|
||||
|
||||
// Close button
|
||||
authDialog?.querySelector(".close-dialog")?.addEventListener("click", () => {
|
||||
authDialog.close();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLoginSubmit(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById("login-email").value;
|
||||
const password = document.getElementById("login-password").value;
|
||||
const errorEl = document.getElementById("login-error");
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
|
||||
// Disable button while processing
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const { error } = await authModule.signIn(email, password);
|
||||
|
||||
submitBtn.disabled = false;
|
||||
|
||||
if (error) {
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
document.getElementById("auth-dialog").close();
|
||||
track("auth_login", { method: "email" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignupSubmit(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById("signup-email").value;
|
||||
const password = document.getElementById("signup-password").value;
|
||||
const confirm = document.getElementById("signup-confirm").value;
|
||||
const errorEl = document.getElementById("signup-error");
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
|
||||
if (password !== confirm) {
|
||||
errorEl.textContent = t("authPasswordMismatch") || "Passwords do not match";
|
||||
errorEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button while processing
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const { error } = await authModule.signUp(email, password);
|
||||
|
||||
submitBtn.disabled = false;
|
||||
|
||||
if (error) {
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
document.getElementById("signup-success")?.classList.add("hidden");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
// Show success message
|
||||
const successEl = document.getElementById("signup-success");
|
||||
successEl?.classList.remove("hidden");
|
||||
// Hide the form fields and button
|
||||
e.target.querySelectorAll(".form-field, button[type='submit']").forEach(el => {
|
||||
el.classList.add("hidden");
|
||||
});
|
||||
track("auth_signup", { method: "email" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetSubmit(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById("reset-email").value;
|
||||
const errorEl = document.getElementById("reset-error");
|
||||
const successEl = document.getElementById("reset-success");
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
|
||||
// Disable button while processing
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const { error } = await authModule.resetPassword(email);
|
||||
|
||||
submitBtn.disabled = false;
|
||||
|
||||
if (error) {
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
successEl.classList.add("hidden");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
successEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showOAuthError(message) {
|
||||
// Show error in the currently visible form's error element
|
||||
const loginError = document.getElementById("login-error");
|
||||
const signupError = document.getElementById("signup-error");
|
||||
|
||||
// Use whichever form is visible
|
||||
const errorEl = !document.getElementById("login-form")?.classList.contains("hidden")
|
||||
? loginError
|
||||
: signupError;
|
||||
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function switchForm(formName) {
|
||||
const loginForm = document.getElementById("login-form");
|
||||
const signupForm = document.getElementById("signup-form");
|
||||
const resetForm = document.getElementById("reset-form");
|
||||
const showSignup = document.getElementById("show-signup");
|
||||
const showLogin = document.getElementById("show-login");
|
||||
const showReset = document.getElementById("show-reset");
|
||||
const titleEl = document.getElementById("auth-dialog-title");
|
||||
const socialSection = document.querySelector(".auth-social");
|
||||
|
||||
// Hide all forms
|
||||
loginForm?.classList.add("hidden");
|
||||
signupForm?.classList.add("hidden");
|
||||
resetForm?.classList.add("hidden");
|
||||
|
||||
// Show the selected form
|
||||
if (formName === "login") {
|
||||
loginForm?.classList.remove("hidden");
|
||||
showSignup?.classList.remove("hidden");
|
||||
showLogin?.classList.add("hidden");
|
||||
showReset?.classList.remove("hidden");
|
||||
socialSection?.classList.remove("hidden");
|
||||
if (titleEl) titleEl.setAttribute("data-i18n", "authLogin");
|
||||
} else if (formName === "signup") {
|
||||
signupForm?.classList.remove("hidden");
|
||||
// Reset signup form to initial state (in case it was showing success)
|
||||
signupForm?.querySelectorAll(".form-field, button[type='submit']").forEach(el => {
|
||||
el.classList.remove("hidden");
|
||||
});
|
||||
signupForm?.reset();
|
||||
showSignup?.classList.add("hidden");
|
||||
showLogin?.classList.remove("hidden");
|
||||
showReset?.classList.add("hidden");
|
||||
socialSection?.classList.remove("hidden");
|
||||
if (titleEl) titleEl.setAttribute("data-i18n", "authSignUp");
|
||||
} else if (formName === "reset") {
|
||||
resetForm?.classList.remove("hidden");
|
||||
showSignup?.classList.add("hidden");
|
||||
showLogin?.classList.remove("hidden");
|
||||
showReset?.classList.add("hidden");
|
||||
socialSection?.classList.add("hidden");
|
||||
if (titleEl) titleEl.setAttribute("data-i18n", "authResetPassword");
|
||||
}
|
||||
|
||||
// Clear error messages
|
||||
document.getElementById("login-error")?.classList.add("hidden");
|
||||
document.getElementById("signup-error")?.classList.add("hidden");
|
||||
document.getElementById("reset-error")?.classList.add("hidden");
|
||||
document.getElementById("reset-success")?.classList.add("hidden");
|
||||
|
||||
// Apply translations to updated elements
|
||||
applyTranslations();
|
||||
}
|
||||
@@ -16,11 +16,24 @@ import htmlElementsEN from "../../lessons/20-html-elements.json";
|
||||
import htmlFormsBasicEN from "../../lessons/21-html-forms-basic.json";
|
||||
import htmlFormsValidationEN from "../../lessons/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryEN from "../../lessons/23-html-details-summary.json";
|
||||
import htmlProgressMeterEN from "../../lessons/24-html-progress-meter.json";
|
||||
import htmlDatalistEN from "../../lessons/25-html-datalist.json";
|
||||
import htmlDialogEN from "../../lessons/27-html-dialog.json";
|
||||
import htmlFieldsetEN from "../../lessons/28-html-forms-fieldset.json";
|
||||
import htmlFigureEN from "../../lessons/29-html-figure.json";
|
||||
import htmlTablesEN from "../../lessons/30-html-tables.json";
|
||||
import htmlSvgEN from "../../lessons/32-html-svg.json";
|
||||
import htmlSemanticEN from "../../lessons/33-html-semantic.json";
|
||||
import flexboxEN from "../../lessons/flexbox.json";
|
||||
import gridEN from "../../lessons/grid.json";
|
||||
import gradientsEN from "../../lessons/09-gradients.json";
|
||||
import filtersEN from "../../lessons/11-filters.json";
|
||||
import positioningEN from "../../lessons/12-positioning.json";
|
||||
import pseudoElementsEN from "../../lessons/13-pseudo-elements.json";
|
||||
import markdownBasicsEN from "../../lessons/40-markdown-basics.json";
|
||||
import jsVariablesEN from "../../lessons/50-js-variables.json";
|
||||
import jsDomEN from "../../lessons/51-js-dom.json";
|
||||
import jsEventsEN from "../../lessons/52-js-events.json";
|
||||
import playgroundEN from "../../lessons/98-playground.json";
|
||||
import goodbyeEN from "../../lessons/99-goodbye.json";
|
||||
|
||||
@@ -35,6 +48,10 @@ import htmlElementsDE from "../../lessons/de/20-html-elements.json";
|
||||
import htmlFormsBasicDE from "../../lessons/de/21-html-forms-basic.json";
|
||||
import htmlFormsValidationDE from "../../lessons/de/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryDE from "../../lessons/de/23-html-details-summary.json";
|
||||
import htmlProgressMeterDE from "../../lessons/de/24-html-progress-meter.json";
|
||||
import htmlDatalistDE from "../../lessons/de/25-html-datalist.json";
|
||||
import htmlDialogDE from "../../lessons/de/27-html-dialog.json";
|
||||
import htmlFieldsetDE from "../../lessons/de/28-html-forms-fieldset.json";
|
||||
import htmlTablesDE from "../../lessons/de/30-html-tables.json";
|
||||
import htmlSvgDE from "../../lessons/de/32-html-svg.json";
|
||||
import flexboxDE from "../../lessons/de/flexbox.json";
|
||||
@@ -50,6 +67,10 @@ import htmlElementsPL from "../../lessons/pl/20-html-elements.json";
|
||||
import htmlFormsBasicPL from "../../lessons/pl/21-html-forms-basic.json";
|
||||
import htmlFormsValidationPL from "../../lessons/pl/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryPL from "../../lessons/pl/23-html-details-summary.json";
|
||||
import htmlProgressMeterPL from "../../lessons/pl/24-html-progress-meter.json";
|
||||
import htmlDatalistPL from "../../lessons/pl/25-html-datalist.json";
|
||||
import htmlDialogPL from "../../lessons/pl/27-html-dialog.json";
|
||||
import htmlFieldsetPL from "../../lessons/pl/28-html-forms-fieldset.json";
|
||||
import htmlTablesPL from "../../lessons/pl/30-html-tables.json";
|
||||
import htmlSvgPL from "../../lessons/pl/32-html-svg.json";
|
||||
import flexboxPL from "../../lessons/pl/flexbox.json";
|
||||
@@ -65,6 +86,10 @@ import htmlElementsES from "../../lessons/es/20-html-elements.json";
|
||||
import htmlFormsBasicES from "../../lessons/es/21-html-forms-basic.json";
|
||||
import htmlFormsValidationES from "../../lessons/es/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryES from "../../lessons/es/23-html-details-summary.json";
|
||||
import htmlProgressMeterES from "../../lessons/es/24-html-progress-meter.json";
|
||||
import htmlDatalistES from "../../lessons/es/25-html-datalist.json";
|
||||
import htmlDialogES from "../../lessons/es/27-html-dialog.json";
|
||||
import htmlFieldsetES from "../../lessons/es/28-html-forms-fieldset.json";
|
||||
import htmlTablesES from "../../lessons/es/30-html-tables.json";
|
||||
import htmlSvgES from "../../lessons/es/32-html-svg.json";
|
||||
import flexboxES from "../../lessons/es/flexbox.json";
|
||||
@@ -80,6 +105,10 @@ import htmlElementsAR from "../../lessons/ar/20-html-elements.json";
|
||||
import htmlFormsBasicAR from "../../lessons/ar/21-html-forms-basic.json";
|
||||
import htmlFormsValidationAR from "../../lessons/ar/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryAR from "../../lessons/ar/23-html-details-summary.json";
|
||||
import htmlProgressMeterAR from "../../lessons/ar/24-html-progress-meter.json";
|
||||
import htmlDatalistAR from "../../lessons/ar/25-html-datalist.json";
|
||||
import htmlDialogAR from "../../lessons/ar/27-html-dialog.json";
|
||||
import htmlFieldsetAR from "../../lessons/ar/28-html-forms-fieldset.json";
|
||||
import htmlTablesAR from "../../lessons/ar/30-html-tables.json";
|
||||
import htmlSvgAR from "../../lessons/ar/32-html-svg.json";
|
||||
import flexboxAR from "../../lessons/ar/flexbox.json";
|
||||
@@ -95,6 +124,10 @@ import htmlElementsUK from "../../lessons/uk/20-html-elements.json";
|
||||
import htmlFormsBasicUK from "../../lessons/uk/21-html-forms-basic.json";
|
||||
import htmlFormsValidationUK from "../../lessons/uk/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryUK from "../../lessons/uk/23-html-details-summary.json";
|
||||
import htmlProgressMeterUK from "../../lessons/uk/24-html-progress-meter.json";
|
||||
import htmlDatalistUK from "../../lessons/uk/25-html-datalist.json";
|
||||
import htmlDialogUK from "../../lessons/uk/27-html-dialog.json";
|
||||
import htmlFieldsetUK from "../../lessons/uk/28-html-forms-fieldset.json";
|
||||
import htmlTablesUK from "../../lessons/uk/30-html-tables.json";
|
||||
import htmlSvgUK from "../../lessons/uk/32-html-svg.json";
|
||||
import flexboxUK from "../../lessons/uk/flexbox.json";
|
||||
@@ -106,24 +139,39 @@ const moduleStoreEN = [
|
||||
// CSS Visual (immediate impact)
|
||||
basicSelectorsEN,
|
||||
colorsEN,
|
||||
gradientsEN,
|
||||
typographyEN,
|
||||
boxModelEN,
|
||||
// CSS Layout
|
||||
flexboxEN,
|
||||
gridEN,
|
||||
positioningEN,
|
||||
unitsVariablesEN,
|
||||
responsiveEN,
|
||||
// CSS Polish
|
||||
transitionsAnimationsEN,
|
||||
filtersEN,
|
||||
pseudoElementsEN,
|
||||
// HTML Structure
|
||||
htmlElementsEN,
|
||||
htmlSemanticEN,
|
||||
htmlFigureEN,
|
||||
htmlSvgEN,
|
||||
// HTML Interactive
|
||||
htmlDetailsSummaryEN,
|
||||
htmlDialogEN,
|
||||
htmlProgressMeterEN,
|
||||
htmlFormsBasicEN,
|
||||
htmlFormsValidationEN,
|
||||
htmlFieldsetEN,
|
||||
htmlDatalistEN,
|
||||
htmlTablesEN,
|
||||
// Markdown
|
||||
markdownBasicsEN,
|
||||
// JavaScript
|
||||
jsVariablesEN,
|
||||
jsDomEN,
|
||||
jsEventsEN,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
playgroundEN
|
||||
@@ -136,24 +184,39 @@ const moduleStoreDE = [
|
||||
// CSS Visual (immediate impact)
|
||||
basicSelectorsDE,
|
||||
colorsEN, // Using EN fallback until translated
|
||||
gradientsEN, // Using EN fallback until translated
|
||||
typographyEN, // Using EN fallback until translated
|
||||
boxModelDE,
|
||||
// CSS Layout
|
||||
flexboxDE,
|
||||
gridEN, // Using EN fallback until translated
|
||||
positioningEN, // Using EN fallback until translated
|
||||
unitsVariablesDE,
|
||||
responsiveDE,
|
||||
// CSS Polish
|
||||
transitionsAnimationsDE,
|
||||
filtersEN, // Using EN fallback until translated
|
||||
pseudoElementsEN, // Using EN fallback until translated
|
||||
// HTML Structure
|
||||
htmlElementsDE,
|
||||
htmlSemanticEN, // Using EN fallback until translated
|
||||
htmlFigureEN, // Using EN fallback until translated
|
||||
htmlSvgDE,
|
||||
// HTML Interactive
|
||||
htmlDetailsSummaryDE,
|
||||
htmlDialogDE,
|
||||
htmlProgressMeterDE,
|
||||
htmlFormsBasicDE,
|
||||
htmlFormsValidationDE,
|
||||
htmlFieldsetDE,
|
||||
htmlDatalistDE,
|
||||
htmlTablesDE,
|
||||
// Markdown
|
||||
markdownBasicsEN, // Using EN fallback until translated
|
||||
// JavaScript
|
||||
jsVariablesEN, // Using EN fallback until translated
|
||||
jsDomEN, // Using EN fallback until translated
|
||||
jsEventsEN, // Using EN fallback until translated
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
playgroundEN
|
||||
@@ -166,24 +229,39 @@ const moduleStorePL = [
|
||||
// CSS Visual (immediate impact)
|
||||
basicSelectorsPL,
|
||||
colorsEN, // Using EN fallback until translated
|
||||
gradientsEN, // Using EN fallback until translated
|
||||
typographyEN, // Using EN fallback until translated
|
||||
boxModelPL,
|
||||
// CSS Layout
|
||||
flexboxPL,
|
||||
gridEN, // Using EN fallback until translated
|
||||
positioningEN, // Using EN fallback until translated
|
||||
unitsVariablesPL,
|
||||
responsivePL,
|
||||
// CSS Polish
|
||||
transitionsAnimationsPL,
|
||||
filtersEN, // Using EN fallback until translated
|
||||
pseudoElementsEN, // Using EN fallback until translated
|
||||
// HTML Structure
|
||||
htmlElementsPL,
|
||||
htmlSemanticEN, // Using EN fallback until translated
|
||||
htmlFigureEN, // Using EN fallback until translated
|
||||
htmlSvgPL,
|
||||
// HTML Interactive
|
||||
htmlDetailsSummaryPL,
|
||||
htmlDialogPL,
|
||||
htmlProgressMeterPL,
|
||||
htmlFormsBasicPL,
|
||||
htmlFormsValidationPL,
|
||||
htmlFieldsetPL,
|
||||
htmlDatalistPL,
|
||||
htmlTablesPL,
|
||||
// Markdown
|
||||
markdownBasicsEN, // Using EN fallback until translated
|
||||
// JavaScript
|
||||
jsVariablesEN, // Using EN fallback until translated
|
||||
jsDomEN, // Using EN fallback until translated
|
||||
jsEventsEN, // Using EN fallback until translated
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
playgroundEN
|
||||
@@ -196,24 +274,39 @@ const moduleStoreES = [
|
||||
// CSS Visual (immediate impact)
|
||||
basicSelectorsES,
|
||||
colorsEN, // Using EN fallback until translated
|
||||
gradientsEN, // Using EN fallback until translated
|
||||
typographyEN, // Using EN fallback until translated
|
||||
boxModelES,
|
||||
// CSS Layout
|
||||
flexboxES,
|
||||
gridEN, // Using EN fallback until translated
|
||||
positioningEN, // Using EN fallback until translated
|
||||
unitsVariablesES,
|
||||
responsiveES,
|
||||
// CSS Polish
|
||||
transitionsAnimationsES,
|
||||
filtersEN, // Using EN fallback until translated
|
||||
pseudoElementsEN, // Using EN fallback until translated
|
||||
// HTML Structure
|
||||
htmlElementsES,
|
||||
htmlSemanticEN, // Using EN fallback until translated
|
||||
htmlFigureEN, // Using EN fallback until translated
|
||||
htmlSvgES,
|
||||
// HTML Interactive
|
||||
htmlDetailsSummaryES,
|
||||
htmlDialogES,
|
||||
htmlProgressMeterES,
|
||||
htmlFormsBasicES,
|
||||
htmlFormsValidationES,
|
||||
htmlFieldsetES,
|
||||
htmlDatalistES,
|
||||
htmlTablesES,
|
||||
// Markdown
|
||||
markdownBasicsEN, // Using EN fallback until translated
|
||||
// JavaScript
|
||||
jsVariablesEN, // Using EN fallback until translated
|
||||
jsDomEN, // Using EN fallback until translated
|
||||
jsEventsEN, // Using EN fallback until translated
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
playgroundEN
|
||||
@@ -226,24 +319,39 @@ const moduleStoreAR = [
|
||||
// CSS Visual (immediate impact)
|
||||
basicSelectorsAR,
|
||||
colorsEN, // Using EN fallback until translated
|
||||
gradientsEN, // Using EN fallback until translated
|
||||
typographyEN, // Using EN fallback until translated
|
||||
boxModelAR,
|
||||
// CSS Layout
|
||||
flexboxAR,
|
||||
gridEN, // Using EN fallback until translated
|
||||
positioningEN, // Using EN fallback until translated
|
||||
unitsVariablesAR,
|
||||
responsiveAR,
|
||||
// CSS Polish
|
||||
transitionsAnimationsAR,
|
||||
filtersEN, // Using EN fallback until translated
|
||||
pseudoElementsEN, // Using EN fallback until translated
|
||||
// HTML Structure
|
||||
htmlElementsAR,
|
||||
htmlSemanticEN, // Using EN fallback until translated
|
||||
htmlFigureEN, // Using EN fallback until translated
|
||||
htmlSvgAR,
|
||||
// HTML Interactive
|
||||
htmlDetailsSummaryAR,
|
||||
htmlDialogAR,
|
||||
htmlProgressMeterAR,
|
||||
htmlFormsBasicAR,
|
||||
htmlFormsValidationAR,
|
||||
htmlFieldsetAR,
|
||||
htmlDatalistAR,
|
||||
htmlTablesAR,
|
||||
// Markdown
|
||||
markdownBasicsEN, // Using EN fallback until translated
|
||||
// JavaScript
|
||||
jsVariablesEN, // Using EN fallback until translated
|
||||
jsDomEN, // Using EN fallback until translated
|
||||
jsEventsEN, // Using EN fallback until translated
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
playgroundEN
|
||||
@@ -256,24 +364,39 @@ const moduleStoreUK = [
|
||||
// CSS Visual (immediate impact)
|
||||
basicSelectorsUK,
|
||||
colorsEN, // Using EN fallback until translated
|
||||
gradientsEN, // Using EN fallback until translated
|
||||
typographyEN, // Using EN fallback until translated
|
||||
boxModelUK,
|
||||
// CSS Layout
|
||||
flexboxUK,
|
||||
gridEN, // Using EN fallback until translated
|
||||
positioningEN, // Using EN fallback until translated
|
||||
unitsVariablesUK,
|
||||
responsiveUK,
|
||||
// CSS Polish
|
||||
transitionsAnimationsUK,
|
||||
filtersEN, // Using EN fallback until translated
|
||||
pseudoElementsEN, // Using EN fallback until translated
|
||||
// HTML Structure
|
||||
htmlElementsUK,
|
||||
htmlSemanticEN, // Using EN fallback until translated
|
||||
htmlFigureEN, // Using EN fallback until translated
|
||||
htmlSvgUK,
|
||||
// HTML Interactive
|
||||
htmlDetailsSummaryUK,
|
||||
htmlDialogUK,
|
||||
htmlProgressMeterUK,
|
||||
htmlFormsBasicUK,
|
||||
htmlFormsValidationUK,
|
||||
htmlFieldsetUK,
|
||||
htmlDatalistUK,
|
||||
htmlTablesUK,
|
||||
// Markdown
|
||||
markdownBasicsEN, // Using EN fallback until translated
|
||||
// JavaScript
|
||||
jsVariablesEN, // Using EN fallback until translated
|
||||
jsDomEN, // Using EN fallback until translated
|
||||
jsEventsEN, // Using EN fallback until translated
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
playgroundEN
|
||||
@@ -289,6 +412,58 @@ const moduleStores = {
|
||||
uk: moduleStoreUK
|
||||
};
|
||||
|
||||
/**
|
||||
* Category labels for sidebar section headers.
|
||||
* Maps module IDs to their visual grouping label.
|
||||
*/
|
||||
const moduleCategories = {
|
||||
// CSS Basics
|
||||
"css-basic-selectors": "CSS Basics",
|
||||
colors: "CSS Basics",
|
||||
gradients: "CSS Basics",
|
||||
typography: "CSS Basics",
|
||||
"box-model": "CSS Basics",
|
||||
// CSS Layout
|
||||
flexbox: "CSS Layout",
|
||||
grid: "CSS Layout",
|
||||
positioning: "CSS Layout",
|
||||
"units-variables": "CSS Layout",
|
||||
responsive: "CSS Layout",
|
||||
// CSS Polish
|
||||
"transitions-animations": "CSS Polish",
|
||||
filters: "CSS Polish",
|
||||
"pseudo-elements": "CSS Polish",
|
||||
// HTML Structure
|
||||
"html-elements": "HTML Structure",
|
||||
"html-semantic": "HTML Structure",
|
||||
"html-figure": "HTML Structure",
|
||||
"html-svg": "HTML Structure",
|
||||
// HTML Interactive
|
||||
"html-details-summary": "HTML Interactive",
|
||||
"html-dialog": "HTML Interactive",
|
||||
"html-progress-meter": "HTML Interactive",
|
||||
"html-forms-basic": "HTML Interactive",
|
||||
"html-forms-validation": "HTML Interactive",
|
||||
"html-forms-fieldset": "HTML Interactive",
|
||||
"html-datalist": "HTML Interactive",
|
||||
"html-tables": "HTML Interactive",
|
||||
// Markdown
|
||||
"markdown-basics": "Markdown",
|
||||
// JavaScript
|
||||
"js-variables": "JavaScript",
|
||||
"js-dom": "JavaScript",
|
||||
"js-events": "JavaScript"
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the sidebar category label for a module
|
||||
* @param {string} moduleId - The module ID
|
||||
* @returns {string|null} The category label, or null for uncategorized modules (welcome, outro)
|
||||
*/
|
||||
export function getModuleCategory(moduleId) {
|
||||
return moduleCategories[moduleId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all available modules for a given language
|
||||
* @param {string} language - Language code ('en', 'de', 'pl', 'es', 'ar', 'uk')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ export const sections = {
|
||||
id: "html",
|
||||
title: "HTML",
|
||||
description: "Semantic markup and native elements",
|
||||
color: "#d4637b",
|
||||
color: "#c75b7a",
|
||||
order: 2
|
||||
},
|
||||
tailwind: {
|
||||
@@ -24,6 +24,20 @@ export const sections = {
|
||||
description: "Utility-first CSS framework",
|
||||
color: "#26a69a",
|
||||
order: 3
|
||||
},
|
||||
markdown: {
|
||||
id: "markdown",
|
||||
title: "Markdown",
|
||||
description: "Lightweight markup language for formatting text",
|
||||
color: "#5b8dd9",
|
||||
order: 4
|
||||
},
|
||||
javascript: {
|
||||
id: "javascript",
|
||||
title: "JavaScript",
|
||||
description: "Variables, DOM manipulation, and event handling",
|
||||
color: "#f0c040",
|
||||
order: 5
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,6 +71,8 @@ export function getModuleSection(module) {
|
||||
const mode = module.mode || "css";
|
||||
if (mode === "html") return "html";
|
||||
if (mode === "tailwind") return "tailwind";
|
||||
if (mode === "markdown") return "markdown";
|
||||
if (mode === "javascript") return "javascript";
|
||||
return "css";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,50 @@
|
||||
* Renderer - Handles UI updates for the CSS learning platform
|
||||
*/
|
||||
import { t } from "../i18n.js";
|
||||
import { getModuleCategory } from "../config/lessons.js";
|
||||
|
||||
/**
|
||||
* Compute lesson difficulty based on lesson structure
|
||||
* - Easy: selector is provided in codePrefix (student only writes properties)
|
||||
* - Medium: student writes a simple selector (single element/class)
|
||||
* - Hard: student writes compound selectors (descendant, chained classes, type+class)
|
||||
* @param {Object} lesson - The lesson object
|
||||
* @returns {"easy"|"medium"|"hard"} The computed difficulty
|
||||
*/
|
||||
export function computeLessonDifficulty(lesson) {
|
||||
const codePrefix = lesson.codePrefix || "";
|
||||
const solution = lesson.solution || "";
|
||||
|
||||
// If codePrefix contains an opening brace, selector is provided → Easy
|
||||
if (codePrefix.includes("{")) {
|
||||
return "easy";
|
||||
}
|
||||
|
||||
// No codePrefix with selector - check the solution complexity
|
||||
// Hard: descendant selectors (space before {), chained classes (.a.b), type+class (a.class)
|
||||
const selectorMatch = solution.match(/^([^{]+)\{/);
|
||||
if (selectorMatch) {
|
||||
const selector = selectorMatch[1].trim();
|
||||
|
||||
// Descendant selector: has space (e.g., ".nav a", ".card p")
|
||||
if (/\S\s+\S/.test(selector)) {
|
||||
return "hard";
|
||||
}
|
||||
|
||||
// Chained classes: multiple dots without space (e.g., ".btn.primary")
|
||||
if ((selector.match(/\./g) || []).length > 1) {
|
||||
return "hard";
|
||||
}
|
||||
|
||||
// Type + class: element followed by dot (e.g., "a.btn", "div.card")
|
||||
if (/^[a-z]+\.[a-z]/i.test(selector)) {
|
||||
return "hard";
|
||||
}
|
||||
}
|
||||
|
||||
// Simple selector → Medium
|
||||
return "medium";
|
||||
}
|
||||
|
||||
// Feedback elements cache
|
||||
let feedbackElement = null;
|
||||
@@ -29,8 +73,21 @@ export function renderModuleList(container, modules, onSelectModule, onSelectLes
|
||||
}
|
||||
}
|
||||
|
||||
// Track current category for section headers
|
||||
let currentCategory = null;
|
||||
|
||||
// Create list items for each module
|
||||
modules.forEach((module) => {
|
||||
// Insert section header when category changes
|
||||
const category = getModuleCategory(module.id);
|
||||
if (category && category !== currentCategory) {
|
||||
currentCategory = category;
|
||||
const header = document.createElement("h3");
|
||||
header.className = "module-section-header";
|
||||
header.textContent = category;
|
||||
header.setAttribute("aria-hidden", "true");
|
||||
container.appendChild(header);
|
||||
}
|
||||
// Create module container
|
||||
// Use native <details>/<summary> for expand/collapse
|
||||
const moduleContainer = document.createElement("details");
|
||||
@@ -138,6 +195,42 @@ export function renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl
|
||||
// The LessonEngine will handle this when it's first set
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the difficulty badge (right-aligned in title row)
|
||||
* @param {HTMLElement} container - The container element (lesson-title-row)
|
||||
* @param {Object} lesson - The lesson object
|
||||
*/
|
||||
export function renderDifficultyBadge(container, lesson) {
|
||||
// Remove existing difficulty wrapper if any
|
||||
const existingWrapper = container.querySelector(".difficulty-wrapper");
|
||||
if (existingWrapper) {
|
||||
existingWrapper.remove();
|
||||
}
|
||||
|
||||
// Compute difficulty
|
||||
const difficulty = computeLessonDifficulty(lesson);
|
||||
|
||||
// Create wrapper for right-alignment
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = "difficulty-wrapper";
|
||||
|
||||
// Create badge element with three bars
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `difficulty-badge difficulty-${difficulty}`;
|
||||
badge.setAttribute("aria-label", t(`difficulty_${difficulty}_label`));
|
||||
badge.setAttribute("title", t(`difficulty_${difficulty}`));
|
||||
|
||||
// Add three bars
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "bar";
|
||||
badge.appendChild(bar);
|
||||
}
|
||||
|
||||
wrapper.appendChild(badge);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the level indicator
|
||||
* @param {HTMLElement} element - The level indicator element
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - #css -> CSS section landing
|
||||
* - #html -> HTML section landing
|
||||
* - #tailwind -> Tailwind section landing
|
||||
* - #markdown -> Markdown section landing
|
||||
* - #reference/css -> CSS cheatsheet
|
||||
* - #module/index -> Lesson (e.g., #flexbox/2)
|
||||
*/
|
||||
@@ -26,7 +27,7 @@ export const RouteType = {
|
||||
/**
|
||||
* Valid section IDs
|
||||
*/
|
||||
const SECTIONS = ["css", "html", "tailwind"];
|
||||
const SECTIONS = ["css", "html", "markdown"]; // tailwind temporarily disabled
|
||||
|
||||
/**
|
||||
* Valid language codes for URL-based switching
|
||||
|
||||
@@ -10,6 +10,8 @@ export function validateUserCode(userCode, lesson) {
|
||||
return validateHtmlCode(userCode, lesson);
|
||||
case "tailwind":
|
||||
return validateTailwindClasses(userCode, lesson);
|
||||
case "javascript":
|
||||
return validateJavaScriptCode(userCode, lesson);
|
||||
case "css":
|
||||
default:
|
||||
return validateCssCode(userCode, lesson);
|
||||
@@ -204,6 +206,80 @@ function validateHtmlCode(userHtml, lesson) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user JavaScript code against the lesson requirements
|
||||
* @param {string} userCode - User submitted JavaScript code
|
||||
* @param {Object} lesson - The current lesson object
|
||||
* @returns {Object} Validation result with isValid and message properties
|
||||
*/
|
||||
function validateJavaScriptCode(userCode, lesson) {
|
||||
if (!lesson || !lesson.validations) {
|
||||
return { isValid: true, message: "No validations specified for this lesson." };
|
||||
}
|
||||
|
||||
const validations = lesson.validations;
|
||||
|
||||
let result = {
|
||||
isValid: true,
|
||||
validCases: 0,
|
||||
totalCases: validations.length,
|
||||
message: "Your CODE looks CRISPY!"
|
||||
};
|
||||
|
||||
for (const validation of validations) {
|
||||
const { type, value, message, options } = validation;
|
||||
let validationPassed = false;
|
||||
|
||||
switch (type) {
|
||||
case "contains":
|
||||
validationPassed = containsValidation(userCode, value, options);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Your code should include "${value}".`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "not_contains":
|
||||
validationPassed = !containsValidation(userCode, value, options);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Your code should not include "${value}".`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "regex":
|
||||
validationPassed = regexValidation(userCode, value, options);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || "Your code does not match the expected pattern."
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown JavaScript validation type: ${type}`);
|
||||
validationPassed = true;
|
||||
}
|
||||
|
||||
if (validationPassed) {
|
||||
result.validCases++;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result.validCases = validations.length;
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateTailwindClasses(userClasses, lesson) {
|
||||
if (!lesson || !lesson.validations) {
|
||||
return { isValid: true, message: "No validations specified for this lesson." };
|
||||
|
||||
596
src/i18n.js
596
src/i18n.js
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* CodeEditor - CodeMirror 6 wrapper with Emmet support
|
||||
*/
|
||||
import { EditorState, Prec } from "@codemirror/state";
|
||||
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
||||
import { EditorState, EditorSelection, Prec, StateField, Compartment } from "@codemirror/state";
|
||||
import { EditorView, keymap, placeholder, Decoration } from "@codemirror/view";
|
||||
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
|
||||
import { history } from "@codemirror/commands";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
@@ -60,8 +62,8 @@ const crispyTheme = EditorView.theme(
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
// Syntax highlighting with purple accent
|
||||
const crispyHighlight = HighlightStyle.define([
|
||||
// Default syntax highlighting (blue accent)
|
||||
const defaultHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#c9a6eb" },
|
||||
{ tag: tags.operator, color: "#cdd6f4" },
|
||||
{ tag: tags.variableName, color: "#89b4fa" },
|
||||
@@ -83,8 +85,42 @@ const crispyHighlight = HighlightStyle.define([
|
||||
{ tag: tags.color, color: "#f9e2af" }
|
||||
]);
|
||||
|
||||
// Combined theme export
|
||||
export const crispyEditorTheme = [crispyTheme, syntaxHighlighting(crispyHighlight)];
|
||||
// CSS section highlighting (purple selectors)
|
||||
const cssHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#c9a6eb" },
|
||||
{ tag: tags.operator, color: "#cdd6f4" },
|
||||
{ tag: tags.variableName, color: "#c9a6eb" },
|
||||
{ tag: tags.propertyName, color: "#89b4fa" },
|
||||
{ tag: tags.attributeName, color: "#89b4fa" },
|
||||
{ tag: tags.className, color: "#c9a6eb" },
|
||||
{ tag: tags.tagName, color: "#c9a6eb" },
|
||||
{ tag: tags.string, color: "#a6e3a1" },
|
||||
{ tag: tags.number, color: "#fab387" },
|
||||
{ tag: tags.bool, color: "#fab387" },
|
||||
{ tag: tags.null, color: "#fab387" },
|
||||
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
|
||||
{ tag: tags.bracket, color: "#cdd6f4" },
|
||||
{ tag: tags.punctuation, color: "#cdd6f4" },
|
||||
{ tag: tags.definition(tags.variableName), color: "#c9a6eb" },
|
||||
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
|
||||
{ tag: tags.atom, color: "#c9a6eb" },
|
||||
{ tag: tags.unit, color: "#a6e3a1" },
|
||||
{ tag: tags.color, color: "#f9e2af" }
|
||||
]);
|
||||
|
||||
// Get highlight style based on section
|
||||
function getHighlightForSection(section) {
|
||||
if (section === "css") return cssHighlight;
|
||||
return defaultHighlight;
|
||||
}
|
||||
|
||||
// Get theme with section-specific highlighting
|
||||
export function getEditorTheme(section) {
|
||||
return [crispyTheme, syntaxHighlighting(getHighlightForSection(section))];
|
||||
}
|
||||
|
||||
// Default combined theme export (for backwards compatibility)
|
||||
export const crispyEditorTheme = [crispyTheme, syntaxHighlighting(defaultHighlight)];
|
||||
|
||||
// Custom overrides for editor styling
|
||||
const editorTheme = EditorView.theme(
|
||||
@@ -110,26 +146,148 @@ export class CodeEditor {
|
||||
this.options = options;
|
||||
this.view = null;
|
||||
this.mode = options.mode || "css";
|
||||
this.section = options.section || null;
|
||||
this.onChange = options.onChange || (() => {});
|
||||
// Read-only zones support
|
||||
this.prefixLength = 0;
|
||||
this.suffixLength = 0;
|
||||
this.currentPrefix = "";
|
||||
this.currentSuffix = "";
|
||||
this.readOnlyCompartment = new Compartment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the editor
|
||||
* Initialize the editor (backwards compatible wrapper)
|
||||
*/
|
||||
init(initialValue = "") {
|
||||
return this.initWithContext("", initialValue, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the editor with read-only prefix/suffix zones
|
||||
* @param {string} prefix - Read-only prefix text (e.g., ".card {\n ")
|
||||
* @param {string} initialValue - Editable user code
|
||||
* @param {string} suffix - Read-only suffix text (e.g., "\n}")
|
||||
*/
|
||||
initWithContext(prefix = "", initialValue = "", suffix = "") {
|
||||
// Clear container
|
||||
this.container.innerHTML = "";
|
||||
|
||||
// Store prefix/suffix for re-initialization (e.g., when mode changes)
|
||||
this.currentPrefix = prefix;
|
||||
this.currentSuffix = suffix;
|
||||
this.prefixLength = prefix.length;
|
||||
this.suffixLength = suffix.length;
|
||||
|
||||
const fullDoc = prefix + initialValue + suffix;
|
||||
|
||||
// Get language extension based on mode
|
||||
const langExtension = this.mode === "html" ? html() : css();
|
||||
const langExtension = this.mode === "html" ? html() : this.mode === "markdown" ? markdown() : this.mode === "javascript" ? javascript() : css();
|
||||
|
||||
// Create read-only zones decorations
|
||||
const readOnlyMark = Decoration.mark({ class: "cm-readonly-zone" });
|
||||
|
||||
// StateField to track and provide decorations for read-only zones
|
||||
const readOnlyDecorations = StateField.define({
|
||||
create: (state) => {
|
||||
const decorations = [];
|
||||
if (this.prefixLength > 0) {
|
||||
decorations.push(readOnlyMark.range(0, this.prefixLength));
|
||||
}
|
||||
if (this.suffixLength > 0) {
|
||||
const suffixStart = state.doc.length - this.suffixLength;
|
||||
decorations.push(readOnlyMark.range(suffixStart, state.doc.length));
|
||||
}
|
||||
return Decoration.set(decorations);
|
||||
},
|
||||
update: (decorations, tr) => {
|
||||
if (!tr.docChanged) return decorations;
|
||||
// Recalculate decorations after document changes
|
||||
const newDecorations = [];
|
||||
if (this.prefixLength > 0) {
|
||||
newDecorations.push(readOnlyMark.range(0, this.prefixLength));
|
||||
}
|
||||
if (this.suffixLength > 0) {
|
||||
const suffixStart = tr.state.doc.length - this.suffixLength;
|
||||
newDecorations.push(readOnlyMark.range(suffixStart, tr.state.doc.length));
|
||||
}
|
||||
return Decoration.set(newDecorations);
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f)
|
||||
});
|
||||
|
||||
// Change filter to prevent edits in read-only zones
|
||||
const readOnlyFilter = EditorState.changeFilter.of((tr) => {
|
||||
// If no prefix/suffix, allow all changes
|
||||
if (this.prefixLength === 0 && this.suffixLength === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prefixEnd = this.prefixLength;
|
||||
const suffixStart = tr.startState.doc.length - this.suffixLength;
|
||||
|
||||
// Check all change ranges - allow only changes within [prefixEnd, suffixStart]
|
||||
let blocked = false;
|
||||
tr.changes.iterChangedRanges((fromA, toA) => {
|
||||
// Block if change starts in prefix zone
|
||||
if (fromA < prefixEnd) {
|
||||
blocked = true;
|
||||
}
|
||||
// Block if change extends into suffix zone
|
||||
if (toA > suffixStart) {
|
||||
blocked = true;
|
||||
}
|
||||
});
|
||||
|
||||
return !blocked;
|
||||
});
|
||||
|
||||
// Transaction filter to constrain cursor/selection to editable area
|
||||
const cursorFilter = EditorState.transactionFilter.of((tr) => {
|
||||
// If no prefix/suffix, no constraints needed
|
||||
if (this.prefixLength === 0 && this.suffixLength === 0) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
const prefixEnd = this.prefixLength;
|
||||
const suffixStart = tr.newDoc.length - this.suffixLength;
|
||||
|
||||
// Check if selection needs adjustment
|
||||
const selection = tr.newSelection;
|
||||
let needsAdjustment = false;
|
||||
|
||||
for (const range of selection.ranges) {
|
||||
if (range.from < prefixEnd || range.to > suffixStart) {
|
||||
needsAdjustment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsAdjustment) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
// Clamp selection to editable area
|
||||
const newRanges = selection.ranges.map((range) => {
|
||||
const from = Math.max(prefixEnd, Math.min(suffixStart, range.from));
|
||||
const to = Math.max(prefixEnd, Math.min(suffixStart, range.to));
|
||||
return EditorSelection.range(from, to);
|
||||
});
|
||||
|
||||
return [tr, { selection: EditorSelection.create(newRanges, selection.mainIndex) }];
|
||||
});
|
||||
|
||||
// Build extensions array
|
||||
const extensions = [
|
||||
langExtension,
|
||||
crispyEditorTheme,
|
||||
getEditorTheme(this.section),
|
||||
editorTheme,
|
||||
// History for undo/redo
|
||||
history(),
|
||||
// Read-only zones (decorations, change filter, and cursor constraint)
|
||||
readOnlyDecorations,
|
||||
readOnlyFilter,
|
||||
cursorFilter,
|
||||
// Emmet abbreviation tracking
|
||||
abbreviationTracker(),
|
||||
// High priority keymap for Emmet
|
||||
@@ -149,20 +307,21 @@ export class CodeEditor {
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
this.onChange(this.getValue());
|
||||
// Report only the editable portion to the onChange handler
|
||||
this.onChange(this.getEditableValue());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping
|
||||
];
|
||||
|
||||
// Add placeholder if provided
|
||||
if (this.options.placeholder) {
|
||||
// Add placeholder if provided (only makes sense when no prefix/suffix)
|
||||
if (this.options.placeholder && this.prefixLength === 0 && this.suffixLength === 0) {
|
||||
extensions.push(placeholder(this.options.placeholder));
|
||||
}
|
||||
|
||||
// Create editor state
|
||||
const state = EditorState.create({
|
||||
doc: initialValue,
|
||||
doc: fullDoc,
|
||||
extensions
|
||||
});
|
||||
|
||||
@@ -172,26 +331,47 @@ export class CodeEditor {
|
||||
parent: this.container
|
||||
});
|
||||
|
||||
// Position cursor at start of editable area
|
||||
if (this.prefixLength > 0) {
|
||||
this.view.dispatch({
|
||||
selection: { anchor: this.prefixLength }
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current editor value
|
||||
* Get current full editor value (including prefix/suffix)
|
||||
*/
|
||||
getValue() {
|
||||
return this.view ? this.view.state.doc.toString() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set editor value (preserves history)
|
||||
* Get only the editable portion (excluding prefix/suffix)
|
||||
*/
|
||||
getEditableValue() {
|
||||
if (!this.view) return "";
|
||||
const fullText = this.view.state.doc.toString();
|
||||
const editableEnd = fullText.length - this.suffixLength;
|
||||
return fullText.slice(this.prefixLength, editableEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set editor value in the editable zone only (preserves history)
|
||||
*/
|
||||
setValue(value) {
|
||||
if (!this.view) return;
|
||||
|
||||
// Only replace the editable portion
|
||||
const editableStart = this.prefixLength;
|
||||
const editableEnd = this.view.state.doc.length - this.suffixLength;
|
||||
|
||||
this.view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.view.state.doc.length,
|
||||
from: editableStart,
|
||||
to: editableEnd,
|
||||
insert: value
|
||||
}
|
||||
});
|
||||
@@ -199,9 +379,12 @@ export class CodeEditor {
|
||||
|
||||
/**
|
||||
* Set editor value and clear history (for lesson switching)
|
||||
* @param {string} value - The editable user code (not including prefix/suffix)
|
||||
* @param {string} prefix - Optional read-only prefix
|
||||
* @param {string} suffix - Optional read-only suffix
|
||||
*/
|
||||
setValueAndClearHistory(value) {
|
||||
this.init(value);
|
||||
setValueAndClearHistory(value, prefix = "", suffix = "") {
|
||||
this.initWithContext(prefix, value, suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,8 +394,19 @@ export class CodeEditor {
|
||||
if (this.mode === mode) return;
|
||||
|
||||
this.mode = mode;
|
||||
const currentValue = this.getValue();
|
||||
this.init(currentValue);
|
||||
const editableValue = this.getEditableValue();
|
||||
this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set section for theme (css, html, tailwind)
|
||||
*/
|
||||
setSection(section) {
|
||||
if (this.section === section) return;
|
||||
|
||||
this.section = section;
|
||||
const editableValue = this.getEditableValue();
|
||||
this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
* Single source of truth for lesson state and progress
|
||||
*/
|
||||
import { validateUserCode } from "../helpers/validator.js";
|
||||
import { marked } from "marked";
|
||||
|
||||
// Auth sync - lazy loaded to avoid circular dependencies
|
||||
let authModule = null;
|
||||
async function getAuthModule() {
|
||||
if (!authModule) {
|
||||
try {
|
||||
authModule = await import("../auth.js");
|
||||
} catch (e) {
|
||||
// Auth module not available, skip cloud sync
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return authModule;
|
||||
}
|
||||
|
||||
export class LessonEngine {
|
||||
constructor() {
|
||||
@@ -241,6 +256,62 @@ export class LessonEngine {
|
||||
${htmlWithClasses}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (mode === "markdown") {
|
||||
// For Markdown mode, parse user code to HTML
|
||||
const renderedHtml = marked.parse(this.userCode || "");
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||
<style>${previewBaseCSS || ""}</style>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
|
||||
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||
p { margin: 0.5em 0; }
|
||||
ul, ol { margin: 0.5em 0; padding-left: 2em; }
|
||||
li { margin: 0.25em 0; }
|
||||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
|
||||
a { color: #0366d6; }
|
||||
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
<style>${sandboxCSS || ""}</style>
|
||||
</head>
|
||||
<body>
|
||||
${renderedHtml}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (mode === "javascript") {
|
||||
// For JavaScript mode, inject user code as a script
|
||||
const { codePrefix, codeSuffix } = this.currentLesson;
|
||||
const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`;
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||
<style>${previewBaseCSS || ""}</style>
|
||||
<style>${sandboxCSS || ""}</style>
|
||||
</head>
|
||||
<body>
|
||||
${previewHTML || ""}
|
||||
<script>
|
||||
try {
|
||||
${fullScript}
|
||||
} catch (e) {
|
||||
console.error("Script error:", e);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
// Original CSS mode
|
||||
@@ -335,6 +406,62 @@ export class LessonEngine {
|
||||
${htmlWithClasses}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (mode === "markdown") {
|
||||
// For Markdown mode, parse solution to HTML
|
||||
const renderedHtml = marked.parse(solutionCode || "");
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||
<style>${previewBaseCSS || ""}</style>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
|
||||
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||
p { margin: 0.5em 0; }
|
||||
ul, ol { margin: 0.5em 0; padding-left: 2em; }
|
||||
li { margin: 0.25em 0; }
|
||||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
|
||||
a { color: #0366d6; }
|
||||
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
<style>${sandboxCSS || ""}</style>
|
||||
</head>
|
||||
<body>
|
||||
${renderedHtml}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (mode === "javascript") {
|
||||
// For JavaScript mode, inject solution code as a script
|
||||
const { codePrefix, codeSuffix } = this.currentLesson;
|
||||
const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||
<style>${previewBaseCSS || ""}</style>
|
||||
<style>${sandboxCSS || ""}</style>
|
||||
</head>
|
||||
<body>
|
||||
${previewHTML || ""}
|
||||
<script>
|
||||
try {
|
||||
${fullScript}
|
||||
} catch (e) {
|
||||
console.error("Script error:", e);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
// CSS mode - wrap solution with prefix/suffix
|
||||
@@ -458,10 +585,11 @@ export class LessonEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall progress statistics
|
||||
* @returns {Object} Progress statistics
|
||||
* Get overall progress statistics with milestone data
|
||||
* @returns {Object} Progress statistics including milestone progress
|
||||
*/
|
||||
getProgressStats() {
|
||||
const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100];
|
||||
let totalLessons = 0;
|
||||
let totalCompleted = 0;
|
||||
|
||||
@@ -476,15 +604,30 @@ export class LessonEngine {
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate milestone progress
|
||||
const milestonesReached = MILESTONES.filter((m) => totalCompleted >= m);
|
||||
const currentMilestone = milestonesReached[milestonesReached.length - 1] || 0;
|
||||
const nextMilestone = MILESTONES.find((m) => m > totalCompleted) || 100;
|
||||
const progressToNext =
|
||||
nextMilestone > currentMilestone
|
||||
? Math.round(((totalCompleted - currentMilestone) / (nextMilestone - currentMilestone)) * 100)
|
||||
: 100;
|
||||
|
||||
return {
|
||||
totalLessons,
|
||||
totalCompleted,
|
||||
percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0
|
||||
percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0,
|
||||
// Milestone data
|
||||
milestones: MILESTONES,
|
||||
milestonesReached,
|
||||
currentMilestone,
|
||||
nextMilestone,
|
||||
progressToNext
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save progress to localStorage
|
||||
* Save progress to localStorage and optionally sync to cloud
|
||||
*/
|
||||
saveUserProgress() {
|
||||
try {
|
||||
@@ -494,11 +637,24 @@ export class LessonEngine {
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
localStorage.setItem("codeCrispies.progress", JSON.stringify(progressData));
|
||||
|
||||
// Trigger cloud sync if logged in (debounced)
|
||||
this.triggerCloudSync();
|
||||
} catch (e) {
|
||||
console.error("Error saving progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger cloud sync if user is logged in (debounced)
|
||||
*/
|
||||
async triggerCloudSync() {
|
||||
const auth = await getAuthModule();
|
||||
if (auth?.isLoggedIn()) {
|
||||
auth.debouncedSyncToCloud();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load progress from localStorage
|
||||
*/
|
||||
@@ -521,11 +677,14 @@ export class LessonEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user code to localStorage
|
||||
* Save user code to localStorage and optionally sync to cloud
|
||||
*/
|
||||
saveUserCodeToStorage() {
|
||||
try {
|
||||
localStorage.setItem("codeCrispies.userCode", JSON.stringify(Array.from(this.userCodeMap.entries())));
|
||||
|
||||
// Trigger cloud sync if logged in (debounced)
|
||||
this.triggerCloudSync();
|
||||
} catch (e) {
|
||||
console.error("Error saving user code:", e);
|
||||
}
|
||||
|
||||
402
src/index.html
402
src/index.html
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Code Crispies - Learn HTML & CSS Interactively | Free Coding Practice</title>
|
||||
<title>CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Master HTML, CSS, and Tailwind through hands-on coding exercises. Free, open-source learning platform with instant feedback. No account required."
|
||||
@@ -19,14 +19,14 @@
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://codecrispi.es/" />
|
||||
<meta property="og:title" content="Code Crispies - Learn HTML & CSS Interactively" />
|
||||
<meta property="og:title" content="CODE CRISPIES - Learn HTML & CSS Interactively" />
|
||||
<meta property="og:description" content="Master HTML, CSS, and Tailwind through hands-on coding exercises. Free and open source." />
|
||||
<meta property="og:image" content="https://codecrispi.es/og-image.png" />
|
||||
<meta property="og:site_name" content="Code Crispies" />
|
||||
<meta property="og:site_name" content="CODE CRISPIES" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Code Crispies - Learn HTML & CSS Interactively" />
|
||||
<meta name="twitter:title" content="CODE CRISPIES - Learn HTML & CSS Interactively" />
|
||||
<meta name="twitter:description" content="Master HTML, CSS, and Tailwind through hands-on coding exercises." />
|
||||
<meta name="twitter:image" content="https://codecrispi.es/og-image.png" />
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Code Crispies",
|
||||
"name": "CODE CRISPIES",
|
||||
"description": "Interactive platform for learning HTML, CSS, and Tailwind through hands-on coding exercises",
|
||||
"url": "https://codecrispi.es/",
|
||||
"applicationCategory": "EducationalApplication",
|
||||
@@ -74,9 +74,13 @@
|
||||
<nav class="main-nav" id="main-nav" aria-label="Main sections">
|
||||
<a href="#css" class="nav-link" data-section="css">CSS</a>
|
||||
<a href="#html" class="nav-link" data-section="html">HTML</a>
|
||||
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a>
|
||||
<!-- <a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a> -->
|
||||
<a href="#markdown" class="nav-link" data-section="markdown">Markdown</a>
|
||||
<a href="#javascript" class="nav-link" data-section="javascript">JS</a>
|
||||
<a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a>
|
||||
</nav>
|
||||
<button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button>
|
||||
<span id="user-email-header" class="user-email hidden"></span>
|
||||
<button id="help-btn" class="help-toggle" data-i18n-aria-label="help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -100,7 +104,7 @@
|
||||
</section>
|
||||
|
||||
<section class="why-it-works">
|
||||
<h2 data-i18n="landingWhyTitle">Why Code Crispies Works</h2>
|
||||
<h2 data-i18n="landingWhyTitle">Why CODE CRISPIES Works</h2>
|
||||
<div class="benefits-grid">
|
||||
<article class="benefit-card">
|
||||
<svg class="benefit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -139,7 +143,7 @@
|
||||
</svg>
|
||||
<h3 data-i18n="landingBenefit4Title">Free & Open Source</h3>
|
||||
<p data-i18n="landingBenefit4Text">
|
||||
No account, no paywall, no tracking. Your progress stays in your browser. The code is open for everyone.
|
||||
No paywall, no tracking. Optional account for cloud sync across devices. The code is open for everyone.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
@@ -160,18 +164,78 @@
|
||||
<p data-i18n="landingHtmlDesc">Semantic markup and native elements</p>
|
||||
<span class="section-card-progress" id="html-progress"></span>
|
||||
</a>
|
||||
<!-- Tailwind temporarily disabled
|
||||
<a href="#tailwind" class="section-card" data-section="tailwind">
|
||||
<div class="section-card-icon" style="background: #26a69a">TW</div>
|
||||
<h3>Tailwind CSS</h3>
|
||||
<p data-i18n="landingTailwindDesc">Utility-first CSS framework</p>
|
||||
<span class="section-card-progress" id="tailwind-progress"></span>
|
||||
</a>
|
||||
-->
|
||||
<a href="#markdown" class="section-card" data-section="markdown">
|
||||
<div class="section-card-icon" style="background: #5b8dd9">MD</div>
|
||||
<h3>Markdown</h3>
|
||||
<p data-i18n="landingMarkdownDesc">Lightweight markup for formatting text</p>
|
||||
<span class="section-card-progress" id="markdown-progress"></span>
|
||||
</a>
|
||||
<a href="#javascript" class="section-card" data-section="javascript">
|
||||
<div class="section-card-icon" style="background: #f0c040">JS</div>
|
||||
<h3>JavaScript</h3>
|
||||
<p data-i18n="landingJsDesc">Variables, DOM manipulation, and event handling</p>
|
||||
<span class="section-card-progress" id="javascript-progress"></span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="device-notice" data-i18n-html="deviceNotice">
|
||||
<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="coming-soon">
|
||||
<h2 data-i18n="landingComingSoonTitle">Coming Soon</h2>
|
||||
<div class="coming-soon-grid">
|
||||
<article class="coming-soon-card">
|
||||
<span class="coming-soon-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>
|
||||
</span>
|
||||
<h3 data-i18n="comingSoonAchievementsTitle">Achievements</h3>
|
||||
<p data-i18n="comingSoonAchievementsText">Earn badges as you master new skills. Track your learning milestones.</p>
|
||||
</article>
|
||||
<article class="coming-soon-card">
|
||||
<span class="coming-soon-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
</span>
|
||||
<h3 data-i18n="comingSoonJsTitle">JavaScript</h3>
|
||||
<p data-i18n="comingSoonJsText">Interactive JavaScript lessons with live code execution and DOM manipulation.</p>
|
||||
</article>
|
||||
<article class="coming-soon-card">
|
||||
<span class="coming-soon-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</span>
|
||||
<h3 data-i18n="comingSoonFrameworksTitle">Frameworks</h3>
|
||||
<p data-i18n="comingSoonFrameworksText">React, Vue, and Svelte basics. Build real components step by step.</p>
|
||||
</article>
|
||||
<article class="coming-soon-card">
|
||||
<span class="coming-soon-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||
</span>
|
||||
<h3 data-i18n="comingSoonChallengesTitle">Code Challenges</h3>
|
||||
<p data-i18n="comingSoonChallengesText">Test your skills with timed puzzles. Compete on leaderboards and earn ranks.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="newsletter-signup">
|
||||
<p data-i18n="newsletterText">Want to know when new features launch?</p>
|
||||
<form id="newsletter-form" class="newsletter-form">
|
||||
<input type="email" id="newsletter-email" required data-i18n-placeholder="newsletterPlaceholder" placeholder="your@email.com">
|
||||
<button type="submit" class="btn btn-outline" data-i18n="newsletterButton">Notify Me</button>
|
||||
</form>
|
||||
<p class="newsletter-disclaimer" data-i18n="newsletterDisclaimer">Max once a week. Unsubscribe anytime via mail@codecrispi.es</p>
|
||||
<p id="newsletter-thanks" class="newsletter-thanks hidden" data-i18n="newsletterThanks">Thanks! We'll keep you posted.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="landing-cta">
|
||||
<h2 data-i18n="landingCtaTitle">Start Learning Today</h2>
|
||||
<a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Begin Your Journey</a>
|
||||
<a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Let's get crispy!</a>
|
||||
<p class="cta-sub" data-i18n="landingCtaSub">Free and open source. No account required. Progress saved locally.</p>
|
||||
</section>
|
||||
|
||||
@@ -198,13 +262,18 @@
|
||||
</section>
|
||||
<section class="footer-section footer-support">
|
||||
<h4 data-i18n="footerSupport">Support</h4>
|
||||
<p data-i18n="footerSupportText">Help keep Code Crispies free and open source.</p>
|
||||
<p data-i18n="footerSupportText">Help keep CODE CRISPIES free and open source.</p>
|
||||
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
|
||||
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
</section>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
||||
<p>© <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
||||
<p class="footer-legal">
|
||||
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
|
||||
<span class="footer-separator">·</span>
|
||||
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -227,6 +296,43 @@
|
||||
<!-- Educational content with integrated module links -->
|
||||
<div class="section-intro" id="section-intro"></div>
|
||||
</article>
|
||||
<footer class="section-footer landing-footer">
|
||||
<div class="footer-grid">
|
||||
<section class="footer-section footer-modules">
|
||||
<div id="section-footer-lesson-links" class="footer-links"></div>
|
||||
</section>
|
||||
<section class="footer-section">
|
||||
<h4 data-i18n="footerResources">Resources</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="#reference/css">CSS Reference</a></li>
|
||||
<li><a href="#reference/html">HTML Reference</a></li>
|
||||
<li><a href="#playground/0" data-i18n="footerPlayground">Playground</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="footer-section">
|
||||
<h4 data-i18n="footerAbout">About</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://librete.ch" target="_blank">LibreTECH</a></li>
|
||||
<li><a href="https://git.librete.ch/libretech/code-crispies" target="_blank">Source Code</a></li>
|
||||
<li><a href="https://github.com/nextlevelshit/code-crispies" target="_blank">GitHub</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="footer-section footer-support">
|
||||
<h4 data-i18n="footerSupport">Support</h4>
|
||||
<p data-i18n="footerSupportText">Help keep CODE CRISPIES free and open source.</p>
|
||||
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
|
||||
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
</section>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
||||
<p class="footer-legal">
|
||||
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
|
||||
<span class="footer-separator">·</span>
|
||||
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Reference/Cheatsheet Pages -->
|
||||
@@ -238,13 +344,48 @@
|
||||
<a href="#reference/flexbox" class="ref-nav-link" data-ref="flexbox">Flexbox</a>
|
||||
<a href="#reference/grid" class="ref-nav-link" data-ref="grid">Grid</a>
|
||||
<a href="#reference/html" class="ref-nav-link" data-ref="html">HTML Elements</a>
|
||||
<a href="#reference/markdown" class="ref-nav-link" data-ref="markdown">Markdown</a>
|
||||
</nav>
|
||||
<div class="reference-body" id="reference-body">
|
||||
<!-- Reference content injected by JS -->
|
||||
</div>
|
||||
</article>
|
||||
<footer class="reference-footer">
|
||||
<p>© 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Released into public domain.</span></p>
|
||||
<footer class="reference-footer landing-footer">
|
||||
<div class="footer-grid">
|
||||
<section class="footer-section footer-modules">
|
||||
<div id="ref-footer-lesson-links" class="footer-links"></div>
|
||||
</section>
|
||||
<section class="footer-section">
|
||||
<h4 data-i18n="footerResources">Resources</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="#reference/css">CSS Reference</a></li>
|
||||
<li><a href="#reference/html">HTML Reference</a></li>
|
||||
<li><a href="#playground/0" data-i18n="footerPlayground">Playground</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="footer-section">
|
||||
<h4 data-i18n="footerAbout">About</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://librete.ch" target="_blank">LibreTECH</a></li>
|
||||
<li><a href="https://git.librete.ch/libretech/code-crispies" target="_blank">Source Code</a></li>
|
||||
<li><a href="https://github.com/nextlevelshit/code-crispies" target="_blank">GitHub</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="footer-section footer-support">
|
||||
<h4 data-i18n="footerSupport">Support</h4>
|
||||
<p data-i18n="footerSupportText">Help keep CODE CRISPIES free and open source.</p>
|
||||
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
|
||||
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
</section>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
||||
<p class="footer-legal">
|
||||
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
|
||||
<span class="footer-separator">·</span>
|
||||
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -281,6 +422,7 @@
|
||||
<label for="code-input" class="editor-label" data-i18n="editorLabel">CSS Editor</label>
|
||||
<div class="editor-actions">
|
||||
<div class="editor-tools">
|
||||
<button id="random-template-btn" class="btn btn-icon hidden" title="Load random template"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="3" ry="3"/><circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="17" cy="17" r="1.5" fill="currentColor" stroke="none"/><circle cx="17" cy="7" r="1.5" fill="currentColor" stroke="none"/><circle cx="7" cy="17" r="1.5" fill="currentColor" stroke="none"/></svg></button>
|
||||
<button id="undo-btn" class="btn btn-icon" data-i18n-title="undoTitle" title="Undo (Ctrl+Z)">↶</button>
|
||||
<button id="redo-btn" class="btn btn-icon" data-i18n-title="redoTitle" title="Redo (Ctrl+Shift+Z)">↷</button>
|
||||
<button
|
||||
@@ -291,7 +433,6 @@
|
||||
>
|
||||
⟲
|
||||
</button>
|
||||
<button id="random-template-btn" class="btn btn-icon hidden" title="Load random template"><img src="./dice.svg" alt="" /></button>
|
||||
</div>
|
||||
<button id="run-btn" class="btn btn-run"><img src="./gear.svg" alt="" /><span data-i18n="run">Run</span></button>
|
||||
</div>
|
||||
@@ -339,40 +480,77 @@
|
||||
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">×</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-section sidebar-nav-mobile" aria-label="Learning paths">
|
||||
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
|
||||
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
|
||||
<!-- <a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a> -->
|
||||
<a href="#markdown" class="sidebar-nav-link" data-section="markdown">Markdown</a>
|
||||
<a href="#javascript" class="sidebar-nav-link" data-section="javascript">JavaScript</a>
|
||||
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 data-i18n="progress">Progress</h4>
|
||||
<div class="progress-display" id="progress-display">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
<div class="progress-display milestone-progress" id="progress-display">
|
||||
<div class="milestones" id="milestones">
|
||||
<span class="milestone" data-value="1">1</span>
|
||||
<span class="milestone" data-value="5">5</span>
|
||||
<span class="milestone" data-value="10">10</span>
|
||||
<span class="milestone" data-value="20">20</span>
|
||||
<span class="milestone" data-value="30">30</span>
|
||||
<span class="milestone" data-value="50">50</span>
|
||||
<span class="milestone" data-value="75">75</span>
|
||||
<span class="milestone" data-value="100">100</span>
|
||||
</div>
|
||||
<span class="progress-text" id="progress-text">0% Complete</span>
|
||||
<div class="progress-bar-row">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<span class="progress-current" id="progress-current">0/1</span>
|
||||
</div>
|
||||
<span class="progress-total" id="progress-total">0 of 100 lessons</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-section" aria-label="Lesson navigation">
|
||||
<h4 id="lessons-heading" data-i18n="lessons">Lessons</h4>
|
||||
<div class="module-list" id="module-list" role="tree" aria-labelledby="lessons-heading"></div>
|
||||
|
||||
<div class="sidebar-auth-box">
|
||||
<h4 data-i18n="authAccount">Account</h4>
|
||||
<button id="auth-trigger-sidebar" class="btn btn-outline btn-full" data-i18n="authLogin">Log In</button>
|
||||
<div id="user-menu-sidebar" class="user-menu-sidebar hidden">
|
||||
<span id="user-email-sidebar" class="user-email"></span>
|
||||
<button id="logout-btn-sidebar" class="btn btn-outline btn-full" data-i18n="authLogout">Log Out</button>
|
||||
<button id="delete-account-btn" class="btn btn-text btn-danger btn-full" data-i18n="authDeleteAccount">Delete Account</button>
|
||||
</div>
|
||||
<p class="sidebar-auth-hint" data-i18n="authSyncHint">Log in to sync progress across devices</p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 data-i18n="settings">Settings</h4>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label" data-i18n="language">Language</span>
|
||||
<select id="lang-select" class="lang-select">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="uk">Українська</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="disable-feedback-toggle" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-label" data-i18n="showHints">Show Hints</span>
|
||||
</label>
|
||||
<button id="reset-btn" class="btn btn-text" data-i18n="resetAllProgress">Reset All Progress</button>
|
||||
<div class="settings-card">
|
||||
<label class="settings-row">
|
||||
<span class="settings-label" data-i18n="language">Language</span>
|
||||
<select id="lang-select" class="lang-select">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="uk">Українська</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="settings-row">
|
||||
<span class="settings-label" data-i18n="showHints">Show Hints</span>
|
||||
<input type="checkbox" id="disable-feedback-toggle" class="settings-toggle" checked />
|
||||
</label>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label" data-i18n="resetAllProgress">Reset All Progress</span>
|
||||
<button id="reset-btn" class="btn btn-sm btn-ghost" data-i18n="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
@@ -389,9 +567,9 @@
|
||||
<button id="help-dialog-close" class="dialog-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<h4 data-i18n="aboutTitle">About Code Crispies</h4>
|
||||
<h4 data-i18n="aboutTitle">About CODE CRISPIES</h4>
|
||||
<p data-i18n="aboutText">
|
||||
Code Crispies is a free, open-source platform for learning web development through hands-on exercises. No account required -
|
||||
CODE CRISPIES is a free, open-source platform for learning web development through hands-on exercises. No account required -
|
||||
just start coding!
|
||||
</p>
|
||||
|
||||
@@ -455,7 +633,7 @@
|
||||
</div>
|
||||
|
||||
<h4 data-i18n="contactTitle">Contact & Links</h4>
|
||||
<p data-i18n-html="contactText">Code Crispies is developed by <a href="https://librete.ch" target="_blank">LibreTECH</a></p>
|
||||
<p data-i18n-html="contactText">CODE CRISPIES is developed by <a href="https://librete.ch" target="_blank">LibreTECH</a></p>
|
||||
<ul>
|
||||
<li><a href="https://git.librete.ch/libretech/code-crispies" target="_blank">Gitea</a> – Self-hosted source repository</li>
|
||||
<li><a href="https://github.com/nextlevelshit/code-crispies" target="_blank">GitHub</a> – Public mirror</li>
|
||||
@@ -463,7 +641,7 @@
|
||||
</ul>
|
||||
|
||||
<h4 data-i18n="supportTitle">Support the Project</h4>
|
||||
<p data-i18n="supportText">Help keep Code Crispies free and open source.</p>
|
||||
<p data-i18n="supportText">Help keep CODE CRISPIES free and open source.</p>
|
||||
<div class="help-support" onclick="typeof umami !== 'undefined' && umami.track('support_click', {location: 'help'})">
|
||||
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
|
||||
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
@@ -506,6 +684,22 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Account Confirmation Dialog -->
|
||||
<dialog id="delete-account-dialog" class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="authDeleteDialogTitle">Delete Account</h3>
|
||||
<button id="delete-dialog-close" class="dialog-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<p data-i18n="authDeleteDialogText">Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.</p>
|
||||
<p id="delete-account-error" class="auth-error hidden"></p>
|
||||
<div class="dialog-actions">
|
||||
<button id="cancel-delete" class="btn" data-i18n="cancel">Cancel</button>
|
||||
<button id="confirm-delete" class="btn btn-danger" data-i18n="authDeleteConfirm">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Share Dialog -->
|
||||
<dialog id="share-dialog" class="dialog">
|
||||
<div class="dialog-header">
|
||||
@@ -521,6 +715,136 @@
|
||||
<p id="copy-feedback" class="copy-feedback" data-i18n="urlCopied" hidden>URL copied to clipboard!</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Privacy Policy Dialog -->
|
||||
<dialog id="privacy-dialog" class="dialog legal-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="privacyTitle">Privacy Policy</h3>
|
||||
<button class="dialog-close privacy-dialog-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content legal-content">
|
||||
<p data-i18n="privacyIntro">CODE CRISPIES respects your privacy. This policy explains what data we collect and how we use it.</p>
|
||||
|
||||
<h4 data-i18n="privacyLocalTitle">Local Storage</h4>
|
||||
<p data-i18n="privacyLocalText">Your learning progress, code, and settings are stored locally in your browser. This data never leaves your device unless you create an account.</p>
|
||||
|
||||
<h4 data-i18n="privacyAccountTitle">Account Data (Optional)</h4>
|
||||
<p data-i18n="privacyAccountText">If you create an account, we store your email address and encrypted password to enable cloud sync. Your progress data is synced to our servers (Supabase) so you can access it across devices.</p>
|
||||
|
||||
<h4 data-i18n="privacyNewsletterTitle">Newsletter (Optional)</h4>
|
||||
<p data-i18n="privacyNewsletterText">If you subscribe to our newsletter, we store your email address to send updates about new features. You can unsubscribe anytime.</p>
|
||||
|
||||
<h4 data-i18n="privacyNoTrackingTitle">No Tracking</h4>
|
||||
<p data-i18n="privacyNoTrackingText">We do not use cookies for tracking, analytics, or advertising. We do not share your data with third parties.</p>
|
||||
|
||||
<h4 data-i18n="privacyRightsTitle">Your Rights (GDPR)</h4>
|
||||
<p data-i18n="privacyRightsText">You can delete your account and all associated data at any time from the sidebar menu. For questions or data requests, contact us at mail@codecrispi.es</p>
|
||||
|
||||
<p class="legal-updated" data-i18n="privacyUpdated">Last updated: January 2025</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Imprint Dialog -->
|
||||
<dialog id="imprint-dialog" class="dialog legal-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="imprintTitle">Imprint</h3>
|
||||
<button class="dialog-close imprint-dialog-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content legal-content">
|
||||
<h4 data-i18n="imprintResponsibleTitle">Responsible for content</h4>
|
||||
<p>
|
||||
Michael Czechowski<br>
|
||||
Schnellweg 3<br>
|
||||
70199 Stuttgart<br>
|
||||
Germany
|
||||
</p>
|
||||
|
||||
<h4 data-i18n="imprintContactTitle">Contact</h4>
|
||||
<p>
|
||||
Email: mail@codecrispi.es<br>
|
||||
Website: <a href="https://librete.ch" target="_blank">librete.ch</a>
|
||||
</p>
|
||||
|
||||
<h4 data-i18n="imprintDisclaimerTitle">Disclaimer</h4>
|
||||
<p data-i18n="imprintDisclaimerText">CODE CRISPIES is provided "as is" without warranty. We are not liable for any damages arising from the use of this service. External links are provided for convenience; we are not responsible for their content.</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Auth Dialog -->
|
||||
<dialog id="auth-dialog" class="dialog auth-dialog">
|
||||
<div class="dialog-header">
|
||||
<h2 id="auth-dialog-title" data-i18n="authLogin">Log In</h2>
|
||||
<button class="dialog-close close-dialog" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<!-- Login Form -->
|
||||
<form id="login-form" class="auth-form">
|
||||
<div class="form-field">
|
||||
<label for="login-email" data-i18n="authEmail">Email</label>
|
||||
<input type="email" id="login-email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="login-password" data-i18n="authPassword">Password</label>
|
||||
<input type="password" id="login-password" required minlength="6" autocomplete="current-password">
|
||||
</div>
|
||||
<p id="login-error" class="auth-error hidden"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full" data-i18n="authLogin">Log In</button>
|
||||
</form>
|
||||
|
||||
<!-- Signup Form (hidden by default) -->
|
||||
<form id="signup-form" class="auth-form hidden">
|
||||
<div class="form-field">
|
||||
<label for="signup-email" data-i18n="authEmail">Email</label>
|
||||
<input type="email" id="signup-email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="signup-password" data-i18n="authPassword">Password</label>
|
||||
<input type="password" id="signup-password" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="signup-confirm" data-i18n="authConfirmPassword">Confirm Password</label>
|
||||
<input type="password" id="signup-confirm" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
<p id="signup-error" class="auth-error hidden"></p>
|
||||
<p id="signup-success" class="auth-success hidden" data-i18n="authSignupSuccess">Account created! Check your email to confirm.</p>
|
||||
<button type="submit" class="btn btn-primary btn-full" data-i18n="authSignUp">Sign Up</button>
|
||||
</form>
|
||||
|
||||
<!-- Password Reset Form (hidden by default) -->
|
||||
<form id="reset-form" class="auth-form hidden">
|
||||
<p class="auth-instructions" data-i18n="authResetInstructions">Enter your email to receive a password reset link.</p>
|
||||
<div class="form-field">
|
||||
<label for="reset-email" data-i18n="authEmail">Email</label>
|
||||
<input type="email" id="reset-email" required autocomplete="email">
|
||||
</div>
|
||||
<p id="reset-error" class="auth-error hidden"></p>
|
||||
<p id="reset-success" class="auth-success hidden" data-i18n="authResetSent">Check your email for the reset link.</p>
|
||||
<button type="submit" class="btn btn-primary btn-full" data-i18n="authSendReset">Send Reset Link</button>
|
||||
</form>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div class="auth-social">
|
||||
<div class="auth-divider"><span data-i18n="authOrContinueWith">or continue with</span></div>
|
||||
<div class="auth-social-buttons">
|
||||
<button type="button" id="google-login" class="btn btn-social">
|
||||
<svg class="social-icon" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
||||
Google
|
||||
</button>
|
||||
<button type="button" id="github-login" class="btn btn-social">
|
||||
<svg class="social-icon" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" fill="currentColor"/></svg>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form switcher links -->
|
||||
<div class="auth-links">
|
||||
<button type="button" id="show-signup" class="btn-text" data-i18n="authNoAccount">Don't have an account? Sign up</button>
|
||||
<button type="button" id="show-login" class="btn-text hidden" data-i18n="authHaveAccount">Already have an account? Log in</button>
|
||||
<button type="button" id="show-reset" class="btn-text" data-i18n="authForgotPassword">Forgot password?</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
|
||||
1217
src/main.css
1217
src/main.css
File diff suppressed because it is too large
Load Diff
106
src/supabase.js
Normal file
106
src/supabase.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
// Check if Supabase is configured
|
||||
export const isConfigured = Boolean(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
// Only create client if configured
|
||||
const supabase = isConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: null;
|
||||
|
||||
// Auth helpers - all return null/rejected promise if not configured
|
||||
export const auth = {
|
||||
signUp: (email, password) =>
|
||||
supabase?.auth.signUp({ email, password }) ??
|
||||
Promise.resolve({ data: null, error: { message: "Not configured" } }),
|
||||
|
||||
signIn: (email, password) =>
|
||||
supabase?.auth.signInWithPassword({ email, password }) ??
|
||||
Promise.resolve({ data: null, error: { message: "Not configured" } }),
|
||||
|
||||
signOut: () =>
|
||||
supabase?.auth.signOut() ??
|
||||
Promise.resolve({ error: null }),
|
||||
|
||||
resetPassword: (email) =>
|
||||
supabase?.auth.resetPasswordForEmail(email) ??
|
||||
Promise.resolve({ data: null, error: { message: "Not configured" } }),
|
||||
|
||||
signInWithGoogle: () =>
|
||||
supabase?.auth.signInWithOAuth({ provider: "google" }) ??
|
||||
Promise.resolve({ data: null, error: { message: "Not configured" } }),
|
||||
|
||||
signInWithGitHub: () =>
|
||||
supabase?.auth.signInWithOAuth({ provider: "github" }) ??
|
||||
Promise.resolve({ data: null, error: { message: "Not configured" } }),
|
||||
|
||||
getUser: () =>
|
||||
supabase?.auth.getUser() ??
|
||||
Promise.resolve({ data: { user: null }, error: null }),
|
||||
|
||||
getSession: () =>
|
||||
supabase?.auth.getSession() ??
|
||||
Promise.resolve({ data: { session: null }, error: null }),
|
||||
|
||||
setSession: ({ access_token, refresh_token }) =>
|
||||
supabase?.auth.setSession({ access_token, refresh_token }) ??
|
||||
Promise.resolve({ data: { session: null }, error: { message: "Not configured" } }),
|
||||
|
||||
onAuthStateChange: (callback) =>
|
||||
supabase?.auth.onAuthStateChange(callback) ?? { data: { subscription: { unsubscribe: () => {} } } },
|
||||
|
||||
deleteAccount: async () => {
|
||||
if (!supabase) return { error: { message: "Not configured" } };
|
||||
const { error } = await supabase.rpc("delete_own_account");
|
||||
return { error };
|
||||
},
|
||||
};
|
||||
|
||||
// Progress sync helpers
|
||||
export const progressDB = {
|
||||
async load(userId) {
|
||||
if (!supabase) return { data: null, error: { message: "Not configured" } };
|
||||
const { data, error } = await supabase
|
||||
.from("user_progress")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
return { data, error };
|
||||
},
|
||||
|
||||
async save(userId, progress, userCode, settings, language) {
|
||||
if (!supabase) return { error: { message: "Not configured" } };
|
||||
const { error } = await supabase.from("user_progress").upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
progress,
|
||||
user_code: userCode,
|
||||
settings,
|
||||
language,
|
||||
},
|
||||
{ onConflict: "user_id" }
|
||||
);
|
||||
return { error };
|
||||
},
|
||||
};
|
||||
|
||||
// Newsletter subscription helper
|
||||
export const newsletter = {
|
||||
async subscribe(email) {
|
||||
if (!supabase) return { error: { message: "Not configured" } };
|
||||
// Use insert with ignoreDuplicates since RLS only allows INSERT
|
||||
const { error } = await supabase.from("newsletter_subscribers").insert(
|
||||
{
|
||||
email: email.toLowerCase().trim(),
|
||||
subscribed_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "email", ignoreDuplicates: true }
|
||||
);
|
||||
// Ignore duplicate email errors (already subscribed)
|
||||
if (error?.code === "23505") return { error: null };
|
||||
return { error };
|
||||
},
|
||||
};
|
||||
58
supabase-setup.sql
Normal file
58
supabase-setup.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- CODE CRISPIES - Supabase Database Setup
|
||||
-- Run this in Supabase Dashboard → SQL Editor → New Query
|
||||
|
||||
-- Drop existing objects first
|
||||
DROP FUNCTION IF EXISTS delete_own_account();
|
||||
DROP TABLE IF EXISTS user_progress;
|
||||
DROP TABLE IF EXISTS newsletter_subscribers;
|
||||
|
||||
-- User progress table
|
||||
CREATE TABLE user_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
progress JSONB NOT NULL DEFAULT '{}',
|
||||
user_code JSONB NOT NULL DEFAULT '{}',
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
language TEXT DEFAULT 'en',
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- Newsletter subscribers table
|
||||
CREATE TABLE newsletter_subscribers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
subscribed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE user_progress ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE newsletter_subscribers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can only access their own progress
|
||||
CREATE POLICY "Users can CRUD own progress"
|
||||
ON user_progress FOR ALL
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Anyone can subscribe to newsletter (public insert)
|
||||
CREATE POLICY "Anyone can subscribe to newsletter"
|
||||
ON newsletter_subscribers FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Function to delete own account (called via RPC)
|
||||
CREATE OR REPLACE FUNCTION delete_own_account()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Delete user's progress (CASCADE should handle this, but be explicit)
|
||||
DELETE FROM user_progress WHERE user_id = auth.uid();
|
||||
|
||||
-- Delete the user from auth.users
|
||||
DELETE FROM auth.users WHERE id = auth.uid();
|
||||
END;
|
||||
$$;
|
||||
@@ -27,7 +27,35 @@ describe("Lessons Config Module", () => {
|
||||
modules.forEach((module) => {
|
||||
module.lessons.forEach((lesson) => {
|
||||
expect(lesson.mode).toBeDefined();
|
||||
expect(["html", "css", "tailwind", "playground"]).toContain(lesson.mode);
|
||||
expect(["html", "css", "tailwind", "markdown", "javascript", "playground"]).toContain(lesson.mode);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript modules", () => {
|
||||
test("should include JavaScript modules", async () => {
|
||||
const modules = await loadModules();
|
||||
const moduleIds = modules.map((m) => m.id);
|
||||
|
||||
expect(moduleIds).toContain("js-variables");
|
||||
expect(moduleIds).toContain("js-dom");
|
||||
expect(moduleIds).toContain("js-events");
|
||||
});
|
||||
|
||||
test("JavaScript modules should have correct mode and structure", async () => {
|
||||
const modules = await loadModules();
|
||||
const jsModules = modules.filter((m) => m.mode === "javascript");
|
||||
|
||||
expect(jsModules.length).toBe(3);
|
||||
|
||||
jsModules.forEach((module) => {
|
||||
expect(module.lessons.length).toBeGreaterThanOrEqual(3);
|
||||
module.lessons.forEach((lesson) => {
|
||||
expect(lesson.mode).toBe("javascript");
|
||||
expect(lesson.validations.length).toBeGreaterThan(0);
|
||||
expect(lesson.task).toBeTruthy();
|
||||
expect(lesson.solution).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback } from "../../src/helpers/renderer.js";
|
||||
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback, computeLessonDifficulty } from "../../src/helpers/renderer.js";
|
||||
|
||||
describe("Renderer Module", () => {
|
||||
beforeEach(() => {
|
||||
@@ -176,4 +176,131 @@ describe("Renderer Module", () => {
|
||||
clearFeedback();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLessonDifficulty", () => {
|
||||
test("should return 'easy' when codePrefix contains selector", () => {
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: ".text {\n ",
|
||||
solution: "color: coral;"
|
||||
})).toBe("easy");
|
||||
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "h1, h2, h3 {\n ",
|
||||
solution: "color: steelblue;"
|
||||
})).toBe("easy");
|
||||
});
|
||||
|
||||
test("should return 'medium' for simple type selector", () => {
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: "p {\n color: steelblue;\n}"
|
||||
})).toBe("medium");
|
||||
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: "a {\n color: coral;\n}"
|
||||
})).toBe("medium");
|
||||
});
|
||||
|
||||
test("should return 'medium' for simple class selector", () => {
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: ".badge {\n background: tomato;\n}"
|
||||
})).toBe("medium");
|
||||
});
|
||||
|
||||
test("should return 'hard' for descendant selectors", () => {
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: ".nav a {\n color: white;\n}"
|
||||
})).toBe("hard");
|
||||
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: ".card p {\n font-size: 0.9rem;\n}"
|
||||
})).toBe("hard");
|
||||
});
|
||||
|
||||
test("should return 'hard' for chained class selectors", () => {
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: ".btn.primary {\n background: steelblue;\n}"
|
||||
})).toBe("hard");
|
||||
});
|
||||
|
||||
test("should return 'hard' for type+class selectors", () => {
|
||||
expect(computeLessonDifficulty({
|
||||
codePrefix: "",
|
||||
solution: "a.btn {\n text-decoration: none;\n}"
|
||||
})).toBe("hard");
|
||||
});
|
||||
|
||||
test("should handle missing fields gracefully", () => {
|
||||
expect(computeLessonDifficulty({})).toBe("medium");
|
||||
expect(computeLessonDifficulty({ codePrefix: null })).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderModuleList section headers", () => {
|
||||
const noop = () => {};
|
||||
|
||||
test("inserts section header elements between different category groups", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
const modules = [
|
||||
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
|
||||
{ id: "colors", title: "Colors", lessons: [{ title: "L1" }] },
|
||||
{ id: "flexbox", title: "Flexbox", lessons: [{ title: "L1" }] },
|
||||
{ id: "html-elements", title: "HTML Elements", lessons: [{ title: "L1" }] }
|
||||
];
|
||||
|
||||
renderModuleList(container, modules, noop, noop);
|
||||
|
||||
const headers = container.querySelectorAll(".module-section-header");
|
||||
expect(headers.length).toBe(3); // CSS Basics, CSS Layout, HTML Structure
|
||||
});
|
||||
|
||||
test("section headers display correct category text", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
const modules = [
|
||||
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
|
||||
{ id: "flexbox", title: "Flexbox", lessons: [{ title: "L1" }] }
|
||||
];
|
||||
|
||||
renderModuleList(container, modules, noop, noop);
|
||||
|
||||
const headers = container.querySelectorAll(".module-section-header");
|
||||
expect(headers[0].textContent).toBe("CSS Basics");
|
||||
expect(headers[1].textContent).toBe("CSS Layout");
|
||||
});
|
||||
|
||||
test("no section header is inserted between modules in the same category", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
const modules = [
|
||||
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
|
||||
{ id: "colors", title: "Colors", lessons: [{ title: "L1" }] },
|
||||
{ id: "typography", title: "Typography", lessons: [{ title: "L1" }] }
|
||||
];
|
||||
|
||||
renderModuleList(container, modules, noop, noop);
|
||||
|
||||
const headers = container.querySelectorAll(".module-section-header");
|
||||
expect(headers.length).toBe(1);
|
||||
expect(headers[0].textContent).toBe("CSS Basics");
|
||||
});
|
||||
|
||||
test("Welcome and Outro modules have no section headers", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
const modules = [
|
||||
{ id: "welcome", title: "Welcome", lessons: [{ title: "L1" }] },
|
||||
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
|
||||
{ id: "playground", title: "Playground", lessons: [{ title: "L1" }] }
|
||||
];
|
||||
|
||||
renderModuleList(container, modules, noop, noop);
|
||||
|
||||
const headers = container.querySelectorAll(".module-section-header");
|
||||
expect(headers.length).toBe(1);
|
||||
expect(headers[0].textContent).toBe("CSS Basics");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,6 +226,69 @@ describe("CSS Validator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript Validator", () => {
|
||||
describe("validateUserCode with mode: javascript", () => {
|
||||
it("should pass contains validation for correct code", () => {
|
||||
const userCode = 'const name = "Ada";';
|
||||
const lesson = {
|
||||
mode: "javascript",
|
||||
validations: [{ type: "contains", value: "const", message: "Use const" }]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userCode, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail contains validation for missing code", () => {
|
||||
const userCode = 'var name = "Ada";';
|
||||
const lesson = {
|
||||
mode: "javascript",
|
||||
validations: [{ type: "contains", value: "const", message: "Use const keyword" }]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userCode, lesson);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toBe("Use const keyword");
|
||||
});
|
||||
|
||||
it("should pass regex validation", () => {
|
||||
const userCode = 'const name = "Ada";';
|
||||
const lesson = {
|
||||
mode: "javascript",
|
||||
validations: [{ type: "regex", value: "const\\s+name\\s*=", message: "Declare name" }]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userCode, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle not_contains validation", () => {
|
||||
const userCode = "let score = 0;";
|
||||
const lesson = {
|
||||
mode: "javascript",
|
||||
validations: [{ type: "not_contains", value: "var", message: "Don't use var" }]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userCode, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const failLesson = {
|
||||
mode: "javascript",
|
||||
validations: [{ type: "not_contains", value: "let", message: "Don't use let" }]
|
||||
};
|
||||
|
||||
const failResult = validateUserCode(userCode, failLesson);
|
||||
expect(failResult.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should pass with no validations", () => {
|
||||
const result = validateUserCode("const x = 1;", { mode: "javascript" });
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.message).toContain("No validations specified");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML Validator", () => {
|
||||
describe("validateUserCode with mode: html", () => {
|
||||
it("should validate element_exists correctly", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
|
||||
export default defineConfig((env) => ({
|
||||
base: "/",
|
||||
root: "./src",
|
||||
envDir: "..",
|
||||
publicDir: "../public",
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
|
||||
Reference in New Issue
Block a user