Merge pull request #2 from nextlevelshit/feature/new-lessons
Feature/new lessons
This commit is contained in:
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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"": {
|
||||
"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",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@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",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
@@ -1354,6 +1355,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 +1514,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 +2197,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",
|
||||
@@ -2991,6 +3105,18 @@
|
||||
"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",
|
||||
@@ -3374,7 +3500,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"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@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",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
|
||||
131
src/app.js
131
src/app.js
@@ -6,6 +6,7 @@ import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +165,7 @@ const elements = {
|
||||
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
|
||||
progressFill: document.getElementById("progress-fill"),
|
||||
progressText: document.getElementById("progress-text"),
|
||||
milestonesContainer: document.getElementById("milestones"),
|
||||
resetBtn: document.getElementById("reset-btn"),
|
||||
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
|
||||
|
||||
@@ -306,14 +311,50 @@ 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,
|
||||
|
||||
// Update progress bar - shows overall progress with full gradient
|
||||
const progressPercent = stats.percentComplete || 1;
|
||||
elements.progressFill.style.width = `${progressPercent}%`;
|
||||
elements.progressFill.style.setProperty('--progress-percent', progressPercent);
|
||||
|
||||
// Update progress text - show completed of total lessons
|
||||
elements.progressText.textContent = t("progressTextMilestone", {
|
||||
completed: stats.totalCompleted,
|
||||
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 =================
|
||||
@@ -498,6 +539,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");
|
||||
@@ -620,8 +662,9 @@ function loadCurrentLesson() {
|
||||
elements.lessonTitleRow.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");
|
||||
|
||||
@@ -629,6 +672,7 @@ function loadCurrentLesson() {
|
||||
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 (hide in playground mode)
|
||||
@@ -711,19 +755,16 @@ function updateNavigationButtons() {
|
||||
const engineState = lessonEngine.getCurrentState();
|
||||
const isPlayground = engineState.lesson?.mode === "playground";
|
||||
|
||||
// Hide nav buttons and center controls in playground mode
|
||||
elements.prevBtn.classList.toggle("hidden", isPlayground);
|
||||
// Hide next button in playground mode
|
||||
elements.nextBtn.classList.toggle("hidden", isPlayground);
|
||||
elements.gameControls?.classList.toggle("centered", isPlayground);
|
||||
|
||||
if (!isPlayground) {
|
||||
// Update button states
|
||||
elements.prevBtn.disabled = !engineState.canGoPrev;
|
||||
elements.nextBtn.disabled = !engineState.canGoNext;
|
||||
|
||||
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
|
||||
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
|
||||
}
|
||||
}
|
||||
|
||||
function nextLesson() {
|
||||
const prevModuleId = lessonEngine.getCurrentState().module?.id;
|
||||
@@ -747,7 +788,8 @@ function nextLesson() {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -827,6 +869,8 @@ function runCode() {
|
||||
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) {
|
||||
@@ -893,8 +937,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);
|
||||
|
||||
@@ -2414,12 +2459,18 @@ function init() {
|
||||
// Set timeout to show fallback if loading takes too long
|
||||
loadingTimeout = setTimeout(showLoadingFallback, 3000);
|
||||
|
||||
// Load modules after editor is ready
|
||||
// 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
|
||||
// Initialize URL router for browser back/forward
|
||||
initRouter();
|
||||
|
||||
// Initialize authentication
|
||||
initAuth(lessonEngine);
|
||||
});
|
||||
|
||||
// Sidebar controls
|
||||
elements.menuBtn.addEventListener("click", openSidebar);
|
||||
elements.closeSidebar.addEventListener("click", closeSidebar);
|
||||
@@ -2430,6 +2481,7 @@ function init() {
|
||||
e.preventDefault();
|
||||
navigateTo("");
|
||||
showLandingPage();
|
||||
track("logo_click");
|
||||
});
|
||||
|
||||
// Language select
|
||||
@@ -2482,10 +2534,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
|
||||
@@ -2560,6 +2644,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
|
||||
|
||||
518
src/auth.js
Normal file
518
src/auth.js
Normal file
@@ -0,0 +1,518 @@
|
||||
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 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");
|
||||
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");
|
||||
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 (both header and sidebar)
|
||||
document
|
||||
.getElementById("auth-trigger-header")
|
||||
?.addEventListener("click", () => {
|
||||
authDialog?.showModal();
|
||||
});
|
||||
document
|
||||
.getElementById("auth-trigger-sidebar")
|
||||
?.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,20 @@ 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 playgroundEN from "../../lessons/98-playground.json";
|
||||
import goodbyeEN from "../../lessons/99-goodbye.json";
|
||||
|
||||
@@ -35,6 +44,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 +63,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 +82,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 +101,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 +120,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,23 +135,32 @@ 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,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
@@ -136,23 +174,32 @@ 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,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
@@ -166,23 +213,32 @@ 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,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
@@ -196,23 +252,32 @@ 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,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
@@ -226,23 +291,32 @@ 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,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
@@ -256,23 +330,32 @@ 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,
|
||||
// Outro
|
||||
goodbyeEN,
|
||||
|
||||
382
src/i18n.js
382
src/i18n.js
@@ -30,6 +30,7 @@ const translations = {
|
||||
hideExpected: "Hide Expected",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
back: "Back",
|
||||
levelIndicator: "Lesson {current} of {total}",
|
||||
lessonLabel: "Lesson",
|
||||
|
||||
@@ -39,6 +40,7 @@ const translations = {
|
||||
language: "Language",
|
||||
progress: "Progress",
|
||||
progressText: "{percent}% Complete ({completed}/{total})",
|
||||
progressTextMilestone: "{completed} of {total} lessons completed",
|
||||
lessons: "Lessons",
|
||||
settings: "Settings",
|
||||
showHints: "Show Hints",
|
||||
@@ -118,7 +120,7 @@ const translations = {
|
||||
|
||||
// Landing page
|
||||
landingHeroTitle: "Learn Web Development",
|
||||
landingHeroHighlight: "By Writing Real Code",
|
||||
landingHeroHighlight: "Crispy Code",
|
||||
landingHeroSubtitle: "Master HTML, CSS, and Tailwind through hands-on exercises with instant feedback. Free and open source.",
|
||||
landingCtaStart: "Start Learning NOW",
|
||||
landingWhyTitle: "Why CODE CRISPIES Works",
|
||||
@@ -129,7 +131,7 @@ const translations = {
|
||||
landingBenefit3Title: "Master Real Skills",
|
||||
landingBenefit3Text: "Learn CSS, HTML, and Tailwind the way professionals use them—through hands-on exercises and reference guides.",
|
||||
landingBenefit4Title: "Free & Open Source",
|
||||
landingBenefit4Text: "No account, no paywall, no tracking. Your progress stays in your browser. The code is open for everyone.",
|
||||
landingBenefit4Text: "No paywall, no tracking. Optional account for cloud sync across devices. The code is open for everyone.",
|
||||
landingPathsTitle: "Explore Learning Paths",
|
||||
landingCssDesc: "Styling, layout, and animations",
|
||||
landingHtmlDesc: "Semantic markup and native elements",
|
||||
@@ -137,7 +139,7 @@ const translations = {
|
||||
comingSoon: "Coming Soon",
|
||||
landingCtaTitle: "Start Learning Today",
|
||||
landingCtaSub: "Free and open source. No account required. Progress saved locally.",
|
||||
landingCtaButton: "Begin Your Journey",
|
||||
landingCtaButton: "Let's get crispy!",
|
||||
|
||||
// Coming Soon
|
||||
landingComingSoonTitle: "Coming Soon",
|
||||
@@ -149,6 +151,15 @@ const translations = {
|
||||
comingSoonJsText: "Interactive JavaScript lessons with live code execution and DOM manipulation.",
|
||||
comingSoonFrameworksTitle: "Frameworks",
|
||||
comingSoonFrameworksText: "React, Vue, and Svelte basics. Build real components step by step.",
|
||||
comingSoonChallengesTitle: "Code Challenges",
|
||||
comingSoonChallengesText: "Test your skills with timed puzzles. Compete on leaderboards and earn ranks.",
|
||||
|
||||
// Newsletter
|
||||
newsletterText: "Want to know when new features launch?",
|
||||
newsletterPlaceholder: "your@email.com",
|
||||
newsletterButton: "Notify Me",
|
||||
newsletterThanks: "Thanks! We'll keep you posted.",
|
||||
newsletterDisclaimer: "Max once a week. Unsubscribe anytime via mail@codecrispi.es",
|
||||
|
||||
// Device Notice
|
||||
deviceNotice: "<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.",
|
||||
@@ -161,10 +172,58 @@ const translations = {
|
||||
footerSupport: "Support",
|
||||
footerSupportText: "Help keep CODE CRISPIES free and open source.",
|
||||
footerLicense: "Released into the public domain.",
|
||||
footerPrivacy: "Privacy Policy",
|
||||
footerImprint: "Imprint",
|
||||
|
||||
// Privacy Policy
|
||||
privacyTitle: "Privacy Policy",
|
||||
privacyIntro: "CODE CRISPIES respects your privacy. This policy explains what data we collect and how we use it.",
|
||||
privacyLocalTitle: "Local Storage",
|
||||
privacyLocalText: "Your learning progress, code, and settings are stored locally in your browser. This data never leaves your device unless you create an account.",
|
||||
privacyAccountTitle: "Account Data (Optional)",
|
||||
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.",
|
||||
privacyNewsletterTitle: "Newsletter (Optional)",
|
||||
privacyNewsletterText: "If you subscribe to our newsletter, we store your email address to send updates about new features. You can unsubscribe anytime.",
|
||||
privacyNoTrackingTitle: "No Tracking",
|
||||
privacyNoTrackingText: "We do not use cookies for tracking, analytics, or advertising. We do not share your data with third parties.",
|
||||
privacyRightsTitle: "Your Rights (GDPR)",
|
||||
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",
|
||||
privacyUpdated: "Last updated: January 2025",
|
||||
|
||||
// Imprint
|
||||
imprintTitle: "Imprint",
|
||||
imprintResponsibleTitle: "Responsible for content",
|
||||
imprintContactTitle: "Contact",
|
||||
imprintDisclaimerTitle: "Disclaimer",
|
||||
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.",
|
||||
|
||||
// Help Dialog Support
|
||||
supportTitle: "Support the Project",
|
||||
supportText: "Help keep CODE CRISPIES free and open source."
|
||||
supportText: "Help keep CODE CRISPIES free and open source.",
|
||||
|
||||
// Auth
|
||||
authLogin: "Log In",
|
||||
authSignUp: "Sign Up",
|
||||
authLogout: "Log Out",
|
||||
authEmail: "Email",
|
||||
authPassword: "Password",
|
||||
authConfirmPassword: "Confirm Password",
|
||||
authNoAccount: "Don't have an account? Sign up",
|
||||
authHaveAccount: "Already have an account? Log in",
|
||||
authForgotPassword: "Forgot password?",
|
||||
authResetPassword: "Reset Password",
|
||||
authResetInstructions: "Enter your email to receive a password reset link.",
|
||||
authSendReset: "Send Reset Link",
|
||||
authResetSent: "Check your email for the reset link.",
|
||||
authOrContinueWith: "or continue with",
|
||||
authPasswordMismatch: "Passwords do not match",
|
||||
authSignupSuccess: "Account created! Check your email to confirm.",
|
||||
authAccount: "Account",
|
||||
authSyncHint: "Log in to sync progress across devices",
|
||||
authDeleteAccount: "Delete Account",
|
||||
authDeleteDialogTitle: "Delete Account",
|
||||
authDeleteDialogText: "Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.",
|
||||
authDeleteConfirm: "Delete Account"
|
||||
},
|
||||
|
||||
de: {
|
||||
@@ -194,6 +253,7 @@ const translations = {
|
||||
hideExpected: "Lösung ausblenden",
|
||||
previous: "Zurück",
|
||||
next: "Weiter",
|
||||
back: "Zurück",
|
||||
levelIndicator: "Lektion {current} von {total}",
|
||||
lessonLabel: "Lektion",
|
||||
|
||||
@@ -203,6 +263,7 @@ const translations = {
|
||||
language: "Sprache",
|
||||
progress: "Fortschritt",
|
||||
progressText: "{percent}% abgeschlossen ({completed}/{total})",
|
||||
progressTextMilestone: "{completed} von {total} Lektionen abgeschlossen",
|
||||
lessons: "Lektionen",
|
||||
settings: "Einstellungen",
|
||||
showHints: "Hinweise anzeigen",
|
||||
@@ -283,7 +344,7 @@ const translations = {
|
||||
|
||||
// Landing page
|
||||
landingHeroTitle: "Web Programmierung",
|
||||
landingHeroHighlight: "Selbstständig lernen",
|
||||
landingHeroHighlight: "Crispy Code",
|
||||
landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.",
|
||||
landingCtaStart: "Jetzt starten",
|
||||
landingWhyTitle: "Warum CODE CRISPIES funktioniert",
|
||||
@@ -296,7 +357,7 @@ const translations = {
|
||||
landingBenefit3Title: "Echte Fähigkeiten",
|
||||
landingBenefit3Text: "Lerne CSS, HTML und Tailwind so, wie Profis sie nutzen – durch praktische Übungen und Referenzanleitungen.",
|
||||
landingBenefit4Title: "Frei & Open Source",
|
||||
landingBenefit4Text: "Kein Konto, keine Paywall, kein Tracking. Dein Fortschritt bleibt in deinem Browser. Der Code ist offen für alle.",
|
||||
landingBenefit4Text: "Keine Paywall, kein Tracking. Optionales Konto für Cloud-Sync über Geräte hinweg. Der Code ist offen für alle.",
|
||||
landingPathsTitle: "Lernpfade entdecken",
|
||||
landingCssDesc: "Styling, Layout und Animationen",
|
||||
landingHtmlDesc: "Semantisches Markup und native Elemente",
|
||||
@@ -304,7 +365,7 @@ const translations = {
|
||||
comingSoon: "Bald verfügbar",
|
||||
landingCtaTitle: "Heute noch anfangen",
|
||||
landingCtaSub: "Kostenlos und Open Source. Kein Konto erforderlich. Fortschritt wird lokal gespeichert.",
|
||||
landingCtaButton: "Jetzt erste Schritte machen",
|
||||
landingCtaButton: "Let's get crispy!",
|
||||
|
||||
// Coming Soon
|
||||
landingComingSoonTitle: "Demnächst",
|
||||
@@ -316,6 +377,15 @@ const translations = {
|
||||
comingSoonJsText: "Interaktive JavaScript-Lektionen mit Live-Code-Ausführung und DOM-Manipulation.",
|
||||
comingSoonFrameworksTitle: "Frameworks",
|
||||
comingSoonFrameworksText: "React, Vue und Svelte Grundlagen. Baue echte Komponenten Schritt für Schritt.",
|
||||
comingSoonChallengesTitle: "Code-Herausforderungen",
|
||||
comingSoonChallengesText: "Teste deine Fähigkeiten mit zeitgesteuerten Rätseln. Kämpfe auf Bestenlisten und steige im Rang auf.",
|
||||
|
||||
// Newsletter
|
||||
newsletterText: "Möchtest du erfahren, wenn neue Funktionen erscheinen?",
|
||||
newsletterPlaceholder: "deine@email.de",
|
||||
newsletterButton: "Benachrichtigen",
|
||||
newsletterThanks: "Danke! Wir halten dich auf dem Laufenden.",
|
||||
newsletterDisclaimer: "Max. einmal pro Woche. Jederzeit abmelden über mail@codecrispi.es",
|
||||
|
||||
// Device Notice
|
||||
deviceNotice: "<strong>Am besten auf Desktop oder Tablet (Querformat).</strong> Mobil funktioniert, aber größere Bildschirme machen das Coden einfacher.",
|
||||
@@ -328,10 +398,54 @@ const translations = {
|
||||
footerSupport: "Unterstützen",
|
||||
footerSupportText: "Hilf mit, CODE CRISPIES kostenlos und Open Source zu halten.",
|
||||
footerLicense: "Gemeinfrei (Public Domain).",
|
||||
footerPrivacy: "Datenschutz",
|
||||
footerImprint: "Impressum",
|
||||
privacyTitle: "Datenschutzerklärung",
|
||||
privacyIntro: "CODE CRISPIES respektiert deine Privatsphäre. Diese Richtlinie erklärt, welche Daten wir sammeln und wie wir sie verwenden.",
|
||||
privacyLocalTitle: "Lokale Speicherung",
|
||||
privacyLocalText: "Dein Lernfortschritt, Code und Einstellungen werden lokal in deinem Browser gespeichert. Diese Daten verlassen dein Gerät nicht, es sei denn, du erstellst ein Konto.",
|
||||
privacyAccountTitle: "Kontodaten (Optional)",
|
||||
privacyAccountText: "Wenn du ein Konto erstellst, speichern wir deine E-Mail-Adresse und dein verschlüsseltes Passwort für die Cloud-Synchronisierung.",
|
||||
privacyNewsletterTitle: "Newsletter (Optional)",
|
||||
privacyNewsletterText: "Wenn du unseren Newsletter abonnierst, speichern wir deine E-Mail-Adresse für Updates. Du kannst dich jederzeit abmelden.",
|
||||
privacyNoTrackingTitle: "Kein Tracking",
|
||||
privacyNoTrackingText: "Wir verwenden keine Cookies für Tracking, Analytik oder Werbung. Wir teilen deine Daten nicht mit Dritten.",
|
||||
privacyRightsTitle: "Deine Rechte (DSGVO)",
|
||||
privacyRightsText: "Du kannst dein Konto und alle zugehörigen Daten jederzeit über das Seitenmenü löschen. Bei Fragen: mail@codecrispi.es",
|
||||
privacyUpdated: "Zuletzt aktualisiert: Januar 2025",
|
||||
imprintTitle: "Impressum",
|
||||
imprintResponsibleTitle: "Verantwortlich für den Inhalt",
|
||||
imprintContactTitle: "Kontakt",
|
||||
imprintDisclaimerTitle: "Haftungsausschluss",
|
||||
imprintDisclaimerText: "CODE CRISPIES wird ohne Gewährleistung bereitgestellt. Wir haften nicht für Schäden, die durch die Nutzung entstehen.",
|
||||
|
||||
// Help Dialog Support
|
||||
supportTitle: "Projekt unterstützen",
|
||||
supportText: "Hilf mit, CODE CRISPIES kostenlos und Open Source zu halten."
|
||||
supportText: "Hilf mit, CODE CRISPIES kostenlos und Open Source zu halten.",
|
||||
|
||||
// Auth
|
||||
authLogin: "Anmelden",
|
||||
authSignUp: "Registrieren",
|
||||
authLogout: "Abmelden",
|
||||
authEmail: "E-Mail",
|
||||
authPassword: "Passwort",
|
||||
authConfirmPassword: "Passwort bestätigen",
|
||||
authNoAccount: "Noch kein Konto? Registrieren",
|
||||
authHaveAccount: "Bereits ein Konto? Anmelden",
|
||||
authForgotPassword: "Passwort vergessen?",
|
||||
authResetPassword: "Passwort zurücksetzen",
|
||||
authResetInstructions: "Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen zu erhalten.",
|
||||
authSendReset: "Link senden",
|
||||
authResetSent: "Prüfe deine E-Mails für den Reset-Link.",
|
||||
authOrContinueWith: "oder weiter mit",
|
||||
authPasswordMismatch: "Passwörter stimmen nicht überein",
|
||||
authSignupSuccess: "Konto erstellt! Überprüfe deine E-Mail zur Bestätigung.",
|
||||
authAccount: "Konto",
|
||||
authSyncHint: "Anmelden, um Fortschritt geräteübergreifend zu synchronisieren",
|
||||
authDeleteAccount: "Konto löschen",
|
||||
authDeleteDialogTitle: "Konto löschen",
|
||||
authDeleteDialogText: "Bist du sicher, dass du dein Konto löschen möchtest? Dein gesamter Cloud-Fortschritt wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||
authDeleteConfirm: "Konto löschen"
|
||||
},
|
||||
|
||||
// Polish
|
||||
@@ -362,6 +476,7 @@ const translations = {
|
||||
hideExpected: "Ukryj oczekiwane",
|
||||
previous: "Poprzednia",
|
||||
next: "Następna",
|
||||
back: "Wstecz",
|
||||
levelIndicator: "Lekcja {current} z {total}",
|
||||
lessonLabel: "Lekcja",
|
||||
|
||||
@@ -371,6 +486,7 @@ const translations = {
|
||||
language: "Język",
|
||||
progress: "Postęp",
|
||||
progressText: "{percent}% ukończone ({completed}/{total})",
|
||||
progressTextMilestone: "{completed} z {total} lekcji ukończonych",
|
||||
lessons: "Lekcje",
|
||||
settings: "Ustawienia",
|
||||
showHints: "Pokaż podpowiedzi",
|
||||
@@ -450,7 +566,7 @@ const translations = {
|
||||
|
||||
// Landing page
|
||||
landingHeroTitle: "Naucz się tworzenia stron",
|
||||
landingHeroHighlight: "Pisząc prawdziwy kod",
|
||||
landingHeroHighlight: "Crispy Code",
|
||||
landingHeroSubtitle: "Opanuj HTML, CSS i Tailwind poprzez praktyczne ćwiczenia z natychmiastową informacją zwrotną. Darmowe i open source.",
|
||||
landingCtaStart: "Zacznij TERAZ",
|
||||
landingWhyTitle: "Dlaczego CODE CRISPIES działa",
|
||||
@@ -463,7 +579,7 @@ const translations = {
|
||||
landingBenefit3Text:
|
||||
"Naucz się CSS, HTML i Tailwind tak, jak używają ich profesjonaliści – poprzez praktyczne ćwiczenia i przewodniki referencyjne.",
|
||||
landingBenefit4Title: "Darmowe i Open Source",
|
||||
landingBenefit4Text: "Bez konta, bez paywalla, bez śledzenia. Twój postęp zostaje w przeglądarce. Kod jest otwarty dla wszystkich.",
|
||||
landingBenefit4Text: "Bez paywalla, bez śledzenia. Opcjonalne konto do synchronizacji w chmurze. Kod jest otwarty dla wszystkich.",
|
||||
landingPathsTitle: "Odkryj ścieżki nauki",
|
||||
landingCssDesc: "Stylowanie, układy i animacje",
|
||||
landingHtmlDesc: "Semantyczne znaczniki i natywne elementy",
|
||||
@@ -471,7 +587,7 @@ const translations = {
|
||||
comingSoon: "Wkrótce",
|
||||
landingCtaTitle: "Zacznij naukę już dziś",
|
||||
landingCtaSub: "Darmowe i open source. Bez konta. Postęp zapisywany lokalnie.",
|
||||
landingCtaButton: "Rozpocznij swoją podróż",
|
||||
landingCtaButton: "Let's get crispy!",
|
||||
|
||||
// Coming Soon
|
||||
landingComingSoonTitle: "Wkrótce",
|
||||
@@ -483,6 +599,15 @@ const translations = {
|
||||
comingSoonJsText: "Interaktywne lekcje JavaScript z wykonywaniem kodu na żywo i manipulacją DOM.",
|
||||
comingSoonFrameworksTitle: "Frameworki",
|
||||
comingSoonFrameworksText: "Podstawy React, Vue i Svelte. Buduj prawdziwe komponenty krok po kroku.",
|
||||
comingSoonChallengesTitle: "Wyzwania kodowania",
|
||||
comingSoonChallengesText: "Sprawdź swoje umiejętności w zadaniach na czas. Rywalizuj na tablicach wyników i zdobywaj rangi.",
|
||||
|
||||
// Newsletter
|
||||
newsletterText: "Chcesz wiedzieć, kiedy pojawią się nowe funkcje?",
|
||||
newsletterPlaceholder: "twoj@email.pl",
|
||||
newsletterButton: "Powiadom mnie",
|
||||
newsletterThanks: "Dzięki! Będziemy informować.",
|
||||
newsletterDisclaimer: "Maks. raz w tygodniu. Wypisz się w dowolnym momencie przez mail@codecrispi.es",
|
||||
|
||||
// Device Notice
|
||||
deviceNotice: "<strong>Najlepiej na komputerze lub tablecie (poziomo).</strong> Na telefonie też działa, ale większy ekran ułatwia kodowanie.",
|
||||
@@ -495,10 +620,54 @@ const translations = {
|
||||
footerSupport: "Wsparcie",
|
||||
footerSupportText: "Pomóż utrzymać CODE CRISPIES darmowym i open source.",
|
||||
footerLicense: "Udostępnione jako domena publiczna.",
|
||||
footerPrivacy: "Polityka prywatności",
|
||||
footerImprint: "Informacje prawne",
|
||||
privacyTitle: "Polityka prywatności",
|
||||
privacyIntro: "CODE CRISPIES szanuje Twoją prywatność. Ta polityka wyjaśnia, jakie dane zbieramy i jak je wykorzystujemy.",
|
||||
privacyLocalTitle: "Lokalne przechowywanie",
|
||||
privacyLocalText: "Twój postęp, kod i ustawienia są przechowywane lokalnie w przeglądarce. Dane te nie opuszczają urządzenia, chyba że utworzysz konto.",
|
||||
privacyAccountTitle: "Dane konta (opcjonalne)",
|
||||
privacyAccountText: "Jeśli utworzysz konto, przechowujemy Twój e-mail i zaszyfrowane hasło do synchronizacji w chmurze.",
|
||||
privacyNewsletterTitle: "Newsletter (opcjonalnie)",
|
||||
privacyNewsletterText: "Jeśli zapiszesz się do newslettera, przechowujemy Twój e-mail do wysyłania aktualizacji. Możesz się wypisać w dowolnym momencie.",
|
||||
privacyNoTrackingTitle: "Brak śledzenia",
|
||||
privacyNoTrackingText: "Nie używamy plików cookie do śledzenia, analityki ani reklam. Nie udostępniamy danych osobom trzecim.",
|
||||
privacyRightsTitle: "Twoje prawa (RODO)",
|
||||
privacyRightsText: "Możesz usunąć swoje konto i wszystkie powiązane dane w dowolnym momencie z menu bocznego. Pytania: mail@codecrispi.es",
|
||||
privacyUpdated: "Ostatnia aktualizacja: styczeń 2025",
|
||||
imprintTitle: "Informacje prawne",
|
||||
imprintResponsibleTitle: "Odpowiedzialny za treść",
|
||||
imprintContactTitle: "Kontakt",
|
||||
imprintDisclaimerTitle: "Zastrzeżenie",
|
||||
imprintDisclaimerText: "CODE CRISPIES jest dostarczany bez gwarancji. Nie ponosimy odpowiedzialności za szkody wynikające z korzystania z usługi.",
|
||||
|
||||
// Help Dialog Support
|
||||
supportTitle: "Wesprzyj projekt",
|
||||
supportText: "Pomóż utrzymać CODE CRISPIES darmowym i open source."
|
||||
supportText: "Pomóż utrzymać CODE CRISPIES darmowym i open source.",
|
||||
|
||||
// Auth
|
||||
authLogin: "Zaloguj się",
|
||||
authSignUp: "Zarejestruj się",
|
||||
authLogout: "Wyloguj się",
|
||||
authEmail: "E-mail",
|
||||
authPassword: "Hasło",
|
||||
authConfirmPassword: "Potwierdź hasło",
|
||||
authNoAccount: "Nie masz konta? Zarejestruj się",
|
||||
authHaveAccount: "Masz już konto? Zaloguj się",
|
||||
authForgotPassword: "Zapomniałeś hasła?",
|
||||
authResetPassword: "Resetuj hasło",
|
||||
authResetInstructions: "Podaj swój e-mail, aby otrzymać link do resetowania hasła.",
|
||||
authSendReset: "Wyślij link",
|
||||
authResetSent: "Sprawdź e-mail, aby znaleźć link do resetowania.",
|
||||
authOrContinueWith: "lub kontynuuj przez",
|
||||
authPasswordMismatch: "Hasła nie są zgodne",
|
||||
authSignupSuccess: "Konto utworzone! Sprawdź e-mail, aby potwierdzić.",
|
||||
authAccount: "Konto",
|
||||
authSyncHint: "Zaloguj się, aby synchronizować postępy między urządzeniami",
|
||||
authDeleteAccount: "Usuń konto",
|
||||
authDeleteDialogTitle: "Usuń konto",
|
||||
authDeleteDialogText: "Czy na pewno chcesz usunąć swoje konto? Cały postęp w chmurze zostanie trwale usunięty. Tej operacji nie można cofnąć.",
|
||||
authDeleteConfirm: "Usuń konto"
|
||||
},
|
||||
|
||||
// Spanish
|
||||
@@ -529,6 +698,7 @@ const translations = {
|
||||
hideExpected: "Ocultar esperado",
|
||||
previous: "Anterior",
|
||||
next: "Siguiente",
|
||||
back: "Volver",
|
||||
levelIndicator: "Lección {current} de {total}",
|
||||
lessonLabel: "Lección",
|
||||
|
||||
@@ -538,6 +708,7 @@ const translations = {
|
||||
language: "Idioma",
|
||||
progress: "Progreso",
|
||||
progressText: "{percent}% completado ({completed}/{total})",
|
||||
progressTextMilestone: "{completed} de {total} lecciones completadas",
|
||||
lessons: "Lecciones",
|
||||
settings: "Configuración",
|
||||
showHints: "Mostrar pistas",
|
||||
@@ -618,7 +789,7 @@ const translations = {
|
||||
|
||||
// Landing page
|
||||
landingHeroTitle: "Aprende desarrollo web",
|
||||
landingHeroHighlight: "Escribiendo código real",
|
||||
landingHeroHighlight: "Crispy Code",
|
||||
landingHeroSubtitle:
|
||||
"Domina HTML, CSS y Tailwind a través de ejercicios prácticos con retroalimentación instantánea. Gratis y de código abierto.",
|
||||
landingCtaStart: "Empieza AHORA",
|
||||
@@ -632,7 +803,7 @@ const translations = {
|
||||
landingBenefit3Title: "Habilidades reales",
|
||||
landingBenefit3Text: "Aprende CSS, HTML y Tailwind como los usan los profesionales—a través de ejercicios prácticos y guías de referencia.",
|
||||
landingBenefit4Title: "Gratis y Open Source",
|
||||
landingBenefit4Text: "Sin cuenta, sin paywall, sin rastreo. Tu progreso se queda en tu navegador. El código está abierto para todos.",
|
||||
landingBenefit4Text: "Sin paywall, sin rastreo. Cuenta opcional para sincronización en la nube. El código está abierto para todos.",
|
||||
landingPathsTitle: "Explora rutas de aprendizaje",
|
||||
landingCssDesc: "Estilos, diseño y animaciones",
|
||||
landingHtmlDesc: "Marcado semántico y elementos nativos",
|
||||
@@ -640,7 +811,7 @@ const translations = {
|
||||
comingSoon: "Próximamente",
|
||||
landingCtaTitle: "Empieza a aprender hoy",
|
||||
landingCtaSub: "Gratis y de código abierto. Sin cuenta requerida. Progreso guardado localmente.",
|
||||
landingCtaButton: "Comienza tu viaje",
|
||||
landingCtaButton: "Let's get crispy!",
|
||||
|
||||
// Coming Soon
|
||||
landingComingSoonTitle: "Próximamente",
|
||||
@@ -652,6 +823,15 @@ const translations = {
|
||||
comingSoonJsText: "Lecciones interactivas de JavaScript con ejecución de código en vivo y manipulación del DOM.",
|
||||
comingSoonFrameworksTitle: "Frameworks",
|
||||
comingSoonFrameworksText: "Fundamentos de React, Vue y Svelte. Construye componentes reales paso a paso.",
|
||||
comingSoonChallengesTitle: "Desafíos de código",
|
||||
comingSoonChallengesText: "Pon a prueba tus habilidades con puzzles cronometrados. Compite en clasificaciones y gana rangos.",
|
||||
|
||||
// Newsletter
|
||||
newsletterText: "¿Quieres saber cuando se lancen nuevas funciones?",
|
||||
newsletterPlaceholder: "tu@email.com",
|
||||
newsletterButton: "Notificarme",
|
||||
newsletterThanks: "¡Gracias! Te mantendremos informado.",
|
||||
newsletterDisclaimer: "Máximo una vez por semana. Cancela cuando quieras vía mail@codecrispi.es",
|
||||
|
||||
// Device Notice
|
||||
deviceNotice: "<strong>Mejor en escritorio o tablet (horizontal).</strong> Funciona en móvil, pero pantallas más grandes facilitan la programación.",
|
||||
@@ -664,10 +844,54 @@ const translations = {
|
||||
footerSupport: "Apoyar",
|
||||
footerSupportText: "Ayuda a mantener CODE CRISPIES gratis y de código abierto.",
|
||||
footerLicense: "Liberado al dominio público.",
|
||||
footerPrivacy: "Política de privacidad",
|
||||
footerImprint: "Aviso legal",
|
||||
privacyTitle: "Política de privacidad",
|
||||
privacyIntro: "CODE CRISPIES respeta tu privacidad. Esta política explica qué datos recopilamos y cómo los usamos.",
|
||||
privacyLocalTitle: "Almacenamiento local",
|
||||
privacyLocalText: "Tu progreso, código y configuración se almacenan localmente en tu navegador. Estos datos no salen de tu dispositivo a menos que crees una cuenta.",
|
||||
privacyAccountTitle: "Datos de cuenta (opcional)",
|
||||
privacyAccountText: "Si creas una cuenta, almacenamos tu email y contraseña encriptada para la sincronización en la nube.",
|
||||
privacyNewsletterTitle: "Newsletter (opcional)",
|
||||
privacyNewsletterText: "Si te suscribes al newsletter, almacenamos tu email para enviar actualizaciones. Puedes cancelar en cualquier momento.",
|
||||
privacyNoTrackingTitle: "Sin rastreo",
|
||||
privacyNoTrackingText: "No usamos cookies para rastreo, analíticas o publicidad. No compartimos tus datos con terceros.",
|
||||
privacyRightsTitle: "Tus derechos (RGPD)",
|
||||
privacyRightsText: "Puedes eliminar tu cuenta y todos los datos asociados en cualquier momento desde el menú lateral. Contacto: mail@codecrispi.es",
|
||||
privacyUpdated: "Última actualización: enero 2025",
|
||||
imprintTitle: "Aviso legal",
|
||||
imprintResponsibleTitle: "Responsable del contenido",
|
||||
imprintContactTitle: "Contacto",
|
||||
imprintDisclaimerTitle: "Descargo de responsabilidad",
|
||||
imprintDisclaimerText: "CODE CRISPIES se proporciona sin garantía. No somos responsables de daños derivados del uso de este servicio.",
|
||||
|
||||
// Help Dialog Support
|
||||
supportTitle: "Apoyar el proyecto",
|
||||
supportText: "Ayuda a mantener CODE CRISPIES gratis y de código abierto."
|
||||
supportText: "Ayuda a mantener CODE CRISPIES gratis y de código abierto.",
|
||||
|
||||
// Auth
|
||||
authLogin: "Iniciar sesión",
|
||||
authSignUp: "Registrarse",
|
||||
authLogout: "Cerrar sesión",
|
||||
authEmail: "Correo electrónico",
|
||||
authPassword: "Contraseña",
|
||||
authConfirmPassword: "Confirmar contraseña",
|
||||
authNoAccount: "¿No tienes cuenta? Regístrate",
|
||||
authHaveAccount: "¿Ya tienes cuenta? Inicia sesión",
|
||||
authForgotPassword: "¿Olvidaste tu contraseña?",
|
||||
authResetPassword: "Restablecer contraseña",
|
||||
authResetInstructions: "Ingresa tu correo para recibir un enlace de restablecimiento.",
|
||||
authSendReset: "Enviar enlace",
|
||||
authResetSent: "Revisa tu correo para el enlace de restablecimiento.",
|
||||
authOrContinueWith: "o continúa con",
|
||||
authPasswordMismatch: "Las contraseñas no coinciden",
|
||||
authSignupSuccess: "¡Cuenta creada! Revisa tu correo para confirmar.",
|
||||
authAccount: "Cuenta",
|
||||
authSyncHint: "Inicia sesión para sincronizar tu progreso entre dispositivos",
|
||||
authDeleteAccount: "Eliminar cuenta",
|
||||
authDeleteDialogTitle: "Eliminar cuenta",
|
||||
authDeleteDialogText: "¿Estás seguro de que quieres eliminar tu cuenta? Todo tu progreso en la nube se eliminará permanentemente. Esta acción no se puede deshacer.",
|
||||
authDeleteConfirm: "Eliminar cuenta"
|
||||
},
|
||||
|
||||
// Arabic
|
||||
@@ -698,6 +922,7 @@ const translations = {
|
||||
hideExpected: "إخفاء المتوقع",
|
||||
previous: "السابق",
|
||||
next: "التالي",
|
||||
back: "رجوع",
|
||||
levelIndicator: "الدرس {current} من {total}",
|
||||
lessonLabel: "درس",
|
||||
|
||||
@@ -707,6 +932,7 @@ const translations = {
|
||||
language: "اللغة",
|
||||
progress: "التقدم",
|
||||
progressText: "{percent}% مكتمل ({completed}/{total})",
|
||||
progressTextMilestone: "{completed} من {total} درس مكتمل",
|
||||
lessons: "الدروس",
|
||||
settings: "الإعدادات",
|
||||
showHints: "إظهار التلميحات",
|
||||
@@ -785,7 +1011,7 @@ const translations = {
|
||||
|
||||
// Landing page
|
||||
landingHeroTitle: "تعلم تطوير الويب",
|
||||
landingHeroHighlight: "بكتابة كود حقيقي",
|
||||
landingHeroHighlight: "Crispy Code",
|
||||
landingHeroSubtitle: "أتقن HTML و CSS و Tailwind من خلال تمارين عملية مع ملاحظات فورية. مجاني ومفتوح المصدر.",
|
||||
landingCtaStart: "ابدأ الآن",
|
||||
landingWhyTitle: "لماذا CODE CRISPIES فعال",
|
||||
@@ -796,7 +1022,7 @@ const translations = {
|
||||
landingBenefit3Title: "مهارات حقيقية",
|
||||
landingBenefit3Text: "تعلم CSS و HTML و Tailwind بالطريقة التي يستخدمها المحترفون—من خلال تمارين عملية وأدلة مرجعية.",
|
||||
landingBenefit4Title: "مجاني ومفتوح المصدر",
|
||||
landingBenefit4Text: "بدون حساب، بدون حواجز دفع، بدون تتبع. تقدمك يبقى في متصفحك. الكود مفتوح للجميع.",
|
||||
landingBenefit4Text: "بدون حواجز دفع، بدون تتبع. حساب اختياري للمزامنة السحابية. الكود مفتوح للجميع.",
|
||||
landingPathsTitle: "استكشف مسارات التعلم",
|
||||
landingCssDesc: "التنسيق والتخطيط والرسوم المتحركة",
|
||||
landingHtmlDesc: "الترميز الدلالي والعناصر الأصلية",
|
||||
@@ -804,7 +1030,7 @@ const translations = {
|
||||
comingSoon: "قريباً",
|
||||
landingCtaTitle: "ابدأ التعلم اليوم",
|
||||
landingCtaSub: "مجاني ومفتوح المصدر. لا حاجة لحساب. يُحفظ التقدم محليًا.",
|
||||
landingCtaButton: "ابدأ رحلتك",
|
||||
landingCtaButton: "Let's get crispy!",
|
||||
|
||||
// Coming Soon
|
||||
landingComingSoonTitle: "قريباً",
|
||||
@@ -816,6 +1042,15 @@ const translations = {
|
||||
comingSoonJsText: "دروس تفاعلية في JavaScript مع تنفيذ مباشر للكود والتعامل مع DOM.",
|
||||
comingSoonFrameworksTitle: "أطر العمل",
|
||||
comingSoonFrameworksText: "أساسيات React وVue وSvelte. ابنِ مكونات حقيقية خطوة بخطوة.",
|
||||
comingSoonChallengesTitle: "تحديات البرمجة",
|
||||
comingSoonChallengesText: "اختبر مهاراتك مع ألغاز موقوتة. تنافس على لوحات المتصدرين واكسب الرتب.",
|
||||
|
||||
// Newsletter
|
||||
newsletterText: "هل تريد معرفة متى تُطلق ميزات جديدة؟",
|
||||
newsletterPlaceholder: "بريدك@email.com",
|
||||
newsletterButton: "أبلغني",
|
||||
newsletterThanks: "شكراً! سنبقيك على اطلاع.",
|
||||
newsletterDisclaimer: "مرة واحدة أسبوعياً كحد أقصى. إلغاء الاشتراك في أي وقت عبر mail@codecrispi.es",
|
||||
|
||||
// Device Notice
|
||||
deviceNotice: "<strong>أفضل على الكمبيوتر أو الجهاز اللوحي (أفقي).</strong> يعمل على الجوال، لكن الشاشات الأكبر تسهّل البرمجة.",
|
||||
@@ -828,10 +1063,54 @@ const translations = {
|
||||
footerSupport: "الدعم",
|
||||
footerSupportText: "ساعد في إبقاء CODE CRISPIES مجانيًا ومفتوح المصدر.",
|
||||
footerLicense: "مُطلق للملكية العامة.",
|
||||
footerPrivacy: "سياسة الخصوصية",
|
||||
footerImprint: "البيانات القانونية",
|
||||
privacyTitle: "سياسة الخصوصية",
|
||||
privacyIntro: "CODE CRISPIES يحترم خصوصيتك. توضح هذه السياسة البيانات التي نجمعها وكيف نستخدمها.",
|
||||
privacyLocalTitle: "التخزين المحلي",
|
||||
privacyLocalText: "يتم تخزين تقدمك وكودك وإعداداتك محليًا في متصفحك. لا تغادر هذه البيانات جهازك إلا إذا أنشأت حسابًا.",
|
||||
privacyAccountTitle: "بيانات الحساب (اختياري)",
|
||||
privacyAccountText: "إذا أنشأت حسابًا، نخزن بريدك الإلكتروني وكلمة مرورك المشفرة للمزامنة السحابية.",
|
||||
privacyNewsletterTitle: "النشرة الإخبارية (اختياري)",
|
||||
privacyNewsletterText: "إذا اشتركت في نشرتنا الإخبارية، نخزن بريدك الإلكتروني لإرسال التحديثات. يمكنك إلغاء الاشتراك في أي وقت.",
|
||||
privacyNoTrackingTitle: "بدون تتبع",
|
||||
privacyNoTrackingText: "لا نستخدم ملفات تعريف الارتباط للتتبع أو التحليلات أو الإعلانات. لا نشارك بياناتك مع أطراف ثالثة.",
|
||||
privacyRightsTitle: "حقوقك (GDPR)",
|
||||
privacyRightsText: "يمكنك حذف حسابك وجميع البيانات المرتبطة في أي وقت من القائمة الجانبية. للاستفسارات: mail@codecrispi.es",
|
||||
privacyUpdated: "آخر تحديث: يناير 2025",
|
||||
imprintTitle: "البيانات القانونية",
|
||||
imprintResponsibleTitle: "المسؤول عن المحتوى",
|
||||
imprintContactTitle: "التواصل",
|
||||
imprintDisclaimerTitle: "إخلاء المسؤولية",
|
||||
imprintDisclaimerText: "يتم تقديم CODE CRISPIES دون ضمان. نحن غير مسؤولين عن أي أضرار ناتجة عن استخدام هذه الخدمة.",
|
||||
|
||||
// Help Dialog Support
|
||||
supportTitle: "ادعم المشروع",
|
||||
supportText: "ساعد في إبقاء CODE CRISPIES مجانيًا ومفتوح المصدر."
|
||||
supportText: "ساعد في إبقاء CODE CRISPIES مجانيًا ومفتوح المصدر.",
|
||||
|
||||
// Auth
|
||||
authLogin: "تسجيل الدخول",
|
||||
authSignUp: "إنشاء حساب",
|
||||
authLogout: "تسجيل الخروج",
|
||||
authEmail: "البريد الإلكتروني",
|
||||
authPassword: "كلمة المرور",
|
||||
authConfirmPassword: "تأكيد كلمة المرور",
|
||||
authNoAccount: "ليس لديك حساب؟ سجّل الآن",
|
||||
authHaveAccount: "لديك حساب بالفعل؟ سجّل الدخول",
|
||||
authForgotPassword: "نسيت كلمة المرور؟",
|
||||
authResetPassword: "إعادة تعيين كلمة المرور",
|
||||
authResetInstructions: "أدخل بريدك الإلكتروني لتلقي رابط إعادة التعيين.",
|
||||
authSendReset: "إرسال الرابط",
|
||||
authResetSent: "تحقق من بريدك الإلكتروني للحصول على رابط إعادة التعيين.",
|
||||
authOrContinueWith: "أو تابع باستخدام",
|
||||
authPasswordMismatch: "كلمات المرور غير متطابقة",
|
||||
authSignupSuccess: "تم إنشاء الحساب! تحقق من بريدك الإلكتروني للتأكيد.",
|
||||
authAccount: "الحساب",
|
||||
authSyncHint: "سجّل الدخول لمزامنة التقدم عبر الأجهزة",
|
||||
authDeleteAccount: "حذف الحساب",
|
||||
authDeleteDialogTitle: "حذف الحساب",
|
||||
authDeleteDialogText: "هل أنت متأكد أنك تريد حذف حسابك؟ سيتم حذف جميع تقدمك في السحابة نهائيًا. لا يمكن التراجع عن هذا الإجراء.",
|
||||
authDeleteConfirm: "حذف الحساب"
|
||||
},
|
||||
|
||||
// Ukrainian
|
||||
@@ -862,6 +1141,7 @@ const translations = {
|
||||
hideExpected: "Сховати очікуване",
|
||||
previous: "Попередній",
|
||||
next: "Наступний",
|
||||
back: "Назад",
|
||||
levelIndicator: "Урок {current} з {total}",
|
||||
lessonLabel: "Урок",
|
||||
|
||||
@@ -871,6 +1151,7 @@ const translations = {
|
||||
language: "Мова",
|
||||
progress: "Прогрес",
|
||||
progressText: "{percent}% завершено ({completed}/{total})",
|
||||
progressTextMilestone: "{completed} з {total} уроків завершено",
|
||||
lessons: "Уроки",
|
||||
settings: "Налаштування",
|
||||
showHints: "Показувати підказки",
|
||||
@@ -950,7 +1231,7 @@ const translations = {
|
||||
|
||||
// Landing page
|
||||
landingHeroTitle: "Вивчай веб-розробку",
|
||||
landingHeroHighlight: "Пишучи справжній код",
|
||||
landingHeroHighlight: "Crispy Code",
|
||||
landingHeroSubtitle: "Опануй HTML, CSS та Tailwind через практичні вправи з миттєвим зворотним зв'язком. Безкоштовно та з відкритим кодом.",
|
||||
landingCtaStart: "Почни ЗАРАЗ",
|
||||
landingWhyTitle: "Чому CODE CRISPIES працює",
|
||||
@@ -962,7 +1243,7 @@ const translations = {
|
||||
landingBenefit3Title: "Реальні навички",
|
||||
landingBenefit3Text: "Вивчай CSS, HTML та Tailwind так, як їх використовують професіонали—через практичні вправи та довідники.",
|
||||
landingBenefit4Title: "Безкоштовно та Open Source",
|
||||
landingBenefit4Text: "Без акаунту, без paywall, без відстеження. Твій прогрес залишається у браузері. Код відкритий для всіх.",
|
||||
landingBenefit4Text: "Без paywall, без відстеження. Опціональний акаунт для хмарної синхронізації. Код відкритий для всіх.",
|
||||
landingPathsTitle: "Досліджуй шляхи навчання",
|
||||
landingCssDesc: "Стилізація, макети та анімації",
|
||||
landingHtmlDesc: "Семантична розмітка та нативні елементи",
|
||||
@@ -970,7 +1251,7 @@ const translations = {
|
||||
comingSoon: "Незабаром",
|
||||
landingCtaTitle: "Почни вчитися сьогодні",
|
||||
landingCtaSub: "Безкоштовно та з відкритим кодом. Без реєстрації. Прогрес зберігається локально.",
|
||||
landingCtaButton: "Розпочни свою подорож",
|
||||
landingCtaButton: "Let's get crispy!",
|
||||
|
||||
// Coming Soon
|
||||
landingComingSoonTitle: "Незабаром",
|
||||
@@ -982,6 +1263,15 @@ const translations = {
|
||||
comingSoonJsText: "Інтерактивні уроки JavaScript з виконанням коду в реальному часі та маніпуляцією DOM.",
|
||||
comingSoonFrameworksTitle: "Фреймворки",
|
||||
comingSoonFrameworksText: "Основи React, Vue та Svelte. Створюй справжні компоненти крок за кроком.",
|
||||
comingSoonChallengesTitle: "Кодові виклики",
|
||||
comingSoonChallengesText: "Перевір свої навички в завданнях на час. Змагайся в рейтингах і здобувай ранги.",
|
||||
|
||||
// Newsletter
|
||||
newsletterText: "Хочете дізнатися, коли з'являться нові функції?",
|
||||
newsletterPlaceholder: "ваш@email.com",
|
||||
newsletterButton: "Повідомити мене",
|
||||
newsletterThanks: "Дякуємо! Ми будемо тримати вас в курсі.",
|
||||
newsletterDisclaimer: "Максимум раз на тиждень. Відписатися можна будь-коли через mail@codecrispi.es",
|
||||
|
||||
// Device Notice
|
||||
deviceNotice: "<strong>Найкраще на комп'ютері або планшеті (горизонтально).</strong> На телефоні теж працює, але більший екран полегшує програмування.",
|
||||
@@ -994,10 +1284,54 @@ const translations = {
|
||||
footerSupport: "Підтримка",
|
||||
footerSupportText: "Допоможи зберегти CODE CRISPIES безкоштовним та з відкритим кодом.",
|
||||
footerLicense: "Передано у суспільне надбання.",
|
||||
footerPrivacy: "Політика конфіденційності",
|
||||
footerImprint: "Правова інформація",
|
||||
privacyTitle: "Політика конфіденційності",
|
||||
privacyIntro: "CODE CRISPIES поважає твою приватність. Ця політика пояснює, які дані ми збираємо і як їх використовуємо.",
|
||||
privacyLocalTitle: "Локальне сховище",
|
||||
privacyLocalText: "Твій прогрес, код та налаштування зберігаються локально у браузері. Ці дані не залишають твій пристрій, якщо ти не створюєш акаунт.",
|
||||
privacyAccountTitle: "Дані акаунту (необов'язково)",
|
||||
privacyAccountText: "Якщо ти створюєш акаунт, ми зберігаємо твою електронну пошту та зашифрований пароль для хмарної синхронізації.",
|
||||
privacyNewsletterTitle: "Розсилка (необов'язково)",
|
||||
privacyNewsletterText: "Якщо ти підписуєшся на розсилку, ми зберігаємо твою пошту для надсилання оновлень. Ти можеш відписатися в будь-який час.",
|
||||
privacyNoTrackingTitle: "Без відстеження",
|
||||
privacyNoTrackingText: "Ми не використовуємо файли cookie для відстеження, аналітики чи реклами. Ми не ділимося твоїми даними з третіми сторонами.",
|
||||
privacyRightsTitle: "Твої права (GDPR)",
|
||||
privacyRightsText: "Ти можеш видалити свій акаунт і всі пов'язані дані в будь-який час з бічного меню. Питання: mail@codecrispi.es",
|
||||
privacyUpdated: "Останнє оновлення: січень 2025",
|
||||
imprintTitle: "Правова інформація",
|
||||
imprintResponsibleTitle: "Відповідальний за вміст",
|
||||
imprintContactTitle: "Контакт",
|
||||
imprintDisclaimerTitle: "Застереження",
|
||||
imprintDisclaimerText: "CODE CRISPIES надається без гарантій. Ми не несемо відповідальності за збитки, що виникають внаслідок використання цього сервісу.",
|
||||
|
||||
// Help Dialog Support
|
||||
supportTitle: "Підтримати проєкт",
|
||||
supportText: "Допоможи зберегти CODE CRISPIES безкоштовним та з відкритим кодом."
|
||||
supportText: "Допоможи зберегти CODE CRISPIES безкоштовним та з відкритим кодом.",
|
||||
|
||||
// Auth
|
||||
authLogin: "Увійти",
|
||||
authSignUp: "Зареєструватися",
|
||||
authLogout: "Вийти",
|
||||
authEmail: "Електронна пошта",
|
||||
authPassword: "Пароль",
|
||||
authConfirmPassword: "Підтвердити пароль",
|
||||
authNoAccount: "Немає акаунту? Зареєструйся",
|
||||
authHaveAccount: "Вже є акаунт? Увійди",
|
||||
authForgotPassword: "Забули пароль?",
|
||||
authResetPassword: "Скинути пароль",
|
||||
authResetInstructions: "Введи свою електронну пошту, щоб отримати посилання для скидання.",
|
||||
authSendReset: "Надіслати посилання",
|
||||
authResetSent: "Перевір електронну пошту для посилання на скидання.",
|
||||
authOrContinueWith: "або продовжити через",
|
||||
authPasswordMismatch: "Паролі не співпадають",
|
||||
authSignupSuccess: "Акаунт створено! Перевір електронну пошту для підтвердження.",
|
||||
authAccount: "Акаунт",
|
||||
authSyncHint: "Увійди, щоб синхронізувати прогрес між пристроями",
|
||||
authDeleteAccount: "Видалити акаунт",
|
||||
authDeleteDialogTitle: "Видалити акаунт",
|
||||
authDeleteDialogText: "Ти впевнений, що хочеш видалити свій акаунт? Весь твій хмарний прогрес буде видалено назавжди. Цю дію неможливо скасувати.",
|
||||
authDeleteConfirm: "Видалити акаунт"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
*/
|
||||
import { validateUserCode } from "../helpers/validator.js";
|
||||
|
||||
// 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() {
|
||||
this.currentLesson = null;
|
||||
@@ -458,10 +472,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 +491,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 +524,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 +564,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);
|
||||
}
|
||||
|
||||
229
src/index.html
229
src/index.html
@@ -77,6 +77,8 @@
|
||||
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</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>
|
||||
@@ -139,7 +141,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>
|
||||
@@ -173,37 +175,54 @@
|
||||
<h2 data-i18n="landingComingSoonTitle">Coming Soon</h2>
|
||||
<div class="coming-soon-grid">
|
||||
<article class="coming-soon-card">
|
||||
<span class="coming-soon-icon">🔄</span>
|
||||
<h3 data-i18n="comingSoonSyncTitle">Cloud Sync</h3>
|
||||
<p data-i18n="comingSoonSyncText">Sync your progress across all devices. Start on desktop, continue on tablet.</p>
|
||||
</article>
|
||||
<article class="coming-soon-card">
|
||||
<span class="coming-soon-icon">🏆</span>
|
||||
<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">⚡</span>
|
||||
<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">🧩</span>
|
||||
<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="device-notice">
|
||||
<p data-i18n="deviceNotice">
|
||||
<p data-i18n-html="deviceNotice">
|
||||
<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -237,6 +256,11 @@
|
||||
</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 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>
|
||||
@@ -289,6 +313,11 @@
|
||||
</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 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>
|
||||
@@ -337,6 +366,11 @@
|
||||
</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 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>
|
||||
@@ -374,7 +408,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"><img src="./dice.svg" alt="" /></button>
|
||||
<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
|
||||
@@ -434,17 +468,38 @@
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 data-i18n="progress">Progress</h4>
|
||||
<div class="progress-display" id="progress-display">
|
||||
<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>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<span class="progress-text" id="progress-text">0% Complete</span>
|
||||
<span class="progress-text" id="progress-text">0 of 100</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">
|
||||
@@ -599,6 +654,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">
|
||||
@@ -614,6 +685,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>
|
||||
|
||||
473
src/main.css
473
src/main.css
@@ -662,6 +662,18 @@ kbd {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Persistent glow for completed lessons */
|
||||
.preview-section.completed-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: var(--spacing-md);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: conic-gradient(from 0deg, #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8);
|
||||
filter: blur(30px);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -796,7 +808,6 @@ kbd {
|
||||
}
|
||||
100% {
|
||||
--border-angle: -360deg;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,7 +825,7 @@ kbd {
|
||||
}
|
||||
100% {
|
||||
--border-angle: -360deg;
|
||||
opacity: 0;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,6 +992,7 @@ nav.sidebar-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.sidebar-section h4 {
|
||||
@@ -1007,7 +1019,8 @@ nav.sidebar-section {
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff);
|
||||
background: linear-gradient(90deg, #9163b8, #d45aa0, #1aafb8, #7c4dff);
|
||||
background-size: calc(100% * 100 / var(--progress-percent, 100)) 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
@@ -1018,6 +1031,73 @@ nav.sidebar-section {
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
/* Milestone Progress */
|
||||
.milestone-progress {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.milestones {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.milestone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--light-text);
|
||||
background: var(--border-color);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.milestone.reached {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Each milestone gets a portion of the gradient based on position */
|
||||
.milestone.reached:nth-child(1) { background: #9163b8; }
|
||||
.milestone.reached:nth-child(2) { background: linear-gradient(135deg, #9163b8, #a85dac); }
|
||||
.milestone.reached:nth-child(3) { background: linear-gradient(135deg, #9163b8, #d45aa0); }
|
||||
.milestone.reached:nth-child(4) { background: linear-gradient(135deg, #9163b8, #d45aa0, #e87aac); }
|
||||
.milestone.reached:nth-child(5) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8); }
|
||||
.milestone.reached:nth-child(6) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #4b8ecc); }
|
||||
.milestone.reached:nth-child(7) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); }
|
||||
.milestone.reached:nth-child(8) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); }
|
||||
|
||||
.milestone.current {
|
||||
color: white;
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 2px 8px rgba(145, 99, 184, 0.4);
|
||||
}
|
||||
|
||||
.milestone.next {
|
||||
border: 2px dashed var(--light-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Milestone celebration animation */
|
||||
@keyframes milestone-pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.milestone.just-reached {
|
||||
animation: milestone-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Module List in Sidebar */
|
||||
.module-list {
|
||||
/* No max-height - parent nav.sidebar-section handles overflow */
|
||||
@@ -1200,7 +1280,8 @@ button.lesson-list-item {
|
||||
border-color: var(--section-color, var(--primary-color));
|
||||
}
|
||||
|
||||
.btn-icon img {
|
||||
.btn-icon img,
|
||||
.btn-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
@@ -1238,6 +1319,28 @@ button.lesson-list-item {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
.btn-text.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--danger-color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-text.btn-danger:hover {
|
||||
color: #c82333;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#reset-code-btn {
|
||||
background: var(--section-color, var(--primary-color));
|
||||
color: white;
|
||||
@@ -1493,6 +1596,265 @@ input:checked + .toggle-slider::before {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* ================= AUTH DIALOG ================= */
|
||||
.auth-dialog {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.form-field input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-main);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
color: var(--danger-color);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-success {
|
||||
color: var(--success-color);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-instructions {
|
||||
color: var(--light-text);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.auth-links .btn-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-links .btn-text:hover {
|
||||
color: var(--primary-color-dark, var(--primary-color));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Social Login */
|
||||
.auth-social {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--light-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.auth-social-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-social {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--panel-bg);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.btn-social:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-bg-light);
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Header Auth Button */
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 0.875rem;
|
||||
color: var(--light-text);
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Sidebar Auth Box (dark design) */
|
||||
.sidebar-auth-box {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: #1a1a2e;
|
||||
border-radius: var(--border-radius-md);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.sidebar-auth-box h4 {
|
||||
color: #fff;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.sidebar-auth-box .btn-outline {
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.sidebar-auth-box .btn-outline:hover {
|
||||
background: #2a2a4e;
|
||||
border-color: #666;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-menu-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-menu-sidebar .user-email {
|
||||
max-width: none;
|
||||
word-break: break-all;
|
||||
font-size: 0.875rem;
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-auth-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Footer Legal Links */
|
||||
.footer-legal {
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer-legal .btn-text {
|
||||
color: var(--light-text);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer-legal .btn-text:hover {
|
||||
color: var(--text-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
color: var(--light-text);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Legal Dialogs (Privacy, Imprint) */
|
||||
.legal-dialog {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.legal-content h4 {
|
||||
margin-top: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.legal-content p {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.legal-content a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.legal-updated {
|
||||
margin-top: var(--spacing-md);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
color: var(--lighter-text);
|
||||
}
|
||||
|
||||
/* Project Cards in Help Dialog */
|
||||
.project-cards {
|
||||
display: flex;
|
||||
@@ -1581,9 +1943,7 @@ input:checked + .toggle-slider::before {
|
||||
}
|
||||
|
||||
.nav-link-ref {
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid var(--border-color);
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
@@ -1924,11 +2284,16 @@ input:checked + .toggle-slider::before {
|
||||
}
|
||||
|
||||
.coming-soon-icon {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.coming-soon-icon svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
stroke: var(--section-color);
|
||||
}
|
||||
|
||||
.coming-soon-card h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -1954,6 +2319,71 @@ input:checked + .toggle-slider::before {
|
||||
}
|
||||
}
|
||||
|
||||
/* Newsletter Signup */
|
||||
.newsletter-signup {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.newsletter-signup p {
|
||||
margin: 0;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.newsletter-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.newsletter-form input[type="email"] {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--panel-bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.newsletter-form input[type="email"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--section-color);
|
||||
}
|
||||
|
||||
.newsletter-signup .btn-outline {
|
||||
border: 2px solid var(--section-color);
|
||||
color: var(--section-color);
|
||||
background: transparent;
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.newsletter-signup .btn-outline:hover {
|
||||
background: var(--section-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.newsletter-disclaimer {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.newsletter-thanks {
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.newsletter-thanks.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Device Notice */
|
||||
.device-notice {
|
||||
margin-top: var(--spacing-lg);
|
||||
@@ -2211,12 +2641,12 @@ input:checked + .toggle-slider::before {
|
||||
}
|
||||
|
||||
.section-overview strong {
|
||||
color: var(--primary-dark);
|
||||
color: var(--section-color-dark, var(--primary-dark));
|
||||
}
|
||||
|
||||
.section-overview code {
|
||||
background: var(--primary-bg-light);
|
||||
color: var(--primary-dark);
|
||||
background: rgba(var(--section-color-rgb, 145, 99, 184), 0.1);
|
||||
color: var(--section-color-dark, var(--primary-dark));
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, monospace;
|
||||
@@ -2239,13 +2669,13 @@ input:checked + .toggle-slider::before {
|
||||
|
||||
.topic-text h2 {
|
||||
font-size: 1.25rem;
|
||||
color: var(--primary-dark);
|
||||
color: var(--section-color-dark, var(--primary-dark));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.topic-text h3 {
|
||||
font-size: 1rem;
|
||||
color: var(--primary-dark);
|
||||
color: var(--section-color-dark, var(--primary-dark));
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -2325,8 +2755,8 @@ input:checked + .toggle-slider::before {
|
||||
|
||||
/* Inline code in topic text */
|
||||
.topic-text code {
|
||||
background: var(--primary-bg-light);
|
||||
color: var(--primary-dark);
|
||||
background: rgba(var(--section-color-rgb, 145, 99, 184), 0.1);
|
||||
color: var(--section-color-dark, var(--primary-dark));
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, monospace;
|
||||
@@ -3230,6 +3660,19 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
|
||||
color: #1aafb8;
|
||||
}
|
||||
|
||||
/* Lesson title h2 section colors */
|
||||
body[data-section="css"] #lesson-title {
|
||||
color: #9163b8;
|
||||
}
|
||||
|
||||
body[data-section="html"] #lesson-title {
|
||||
color: #d45aa0;
|
||||
}
|
||||
|
||||
body[data-section="tailwind"] #lesson-title {
|
||||
color: #1aafb8;
|
||||
}
|
||||
|
||||
/* Section and Reference footer - override landing-footer styles */
|
||||
.section-footer.landing-footer,
|
||||
.reference-footer.landing-footer {
|
||||
|
||||
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;
|
||||
$$;
|
||||
@@ -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