diff --git a/.gitignore b/.gitignore index a6848d2..b696bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules dist coverage +.env +.env.local # Claude Code local settings (user-specific) .claude/settings.local.json \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..41d01da --- /dev/null +++ b/docs/ROADMAP.md @@ -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 +
+
+ 1 + 5 + 10 + 20 + +
+
+
+
+ 12 of 100 lessons +
+``` + +--- + +## Success Metrics + +- [ ] 100 total lessons +- [ ] Milestone system implemented +- [ ] All 6 languages have translations +- [ ] Achievement celebrations working +- [ ] Mobile responsive milestone UI diff --git a/lessons/04-typography.json b/lessons/04-typography.json index 490d3d9..9dda4b8 100644 --- a/lessons/04-typography.json +++ b/lessons/04-typography.json @@ -98,6 +98,53 @@ "message": "Set letter-spacing to 1px" } ] + }, + { + "id": "text-decoration", + "title": "Text Decoration", + "description": "The text-decoration property adds lines to text. Common values:

underline — line below text
line-through — strikethrough
none — removes decoration (useful for links)

You can also style decorations with text-decoration-color and text-decoration-style.", + "task": "Show the old price with a strikethrough. Add text-decoration: line-through.", + "previewHTML": "
$49.99$29.99
", + "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 line-through" + } + ] + }, + { + "id": "text-shadow", + "title": "Text Shadow", + "description": "The text-shadow property adds shadow effects to text. The syntax is:

text-shadow: x-offset y-offset blur color;

Example: text-shadow: 2px 2px 4px gray creates a soft shadow offset down and right.", + "task": "Add depth to the heading with text-shadow: 2px 2px 4px gray.", + "previewHTML": "

Welcome

", + "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 text-shadow property" + }, + { + "type": "contains", + "value": "2px 2px", + "message": "Set offset to 2px 2px" + } + ] } ] } diff --git a/lessons/09-gradients.json b/lessons/09-gradients.json new file mode 100644 index 0000000..808c01a --- /dev/null +++ b/lessons/09-gradients.json @@ -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 linear-gradient() function creates a gradient along a straight line.

Basic syntax:
background: linear-gradient(color1, color2);

By default, gradients flow from top to bottom.", + "task": "Add a gradient background from coral to gold.", + "previewHTML": "

Summer Sale

Up to 50% off

", + "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 linear-gradient()" + }, + { + "type": "contains", + "value": "coral", + "message": "Include coral as the first color" + }, + { + "type": "contains", + "value": "gold", + "message": "Include gold as the second color" + } + ] + }, + { + "id": "gradients-2", + "title": "Gradient Direction", + "description": "Control the gradient direction by adding an angle or keyword before the colors.

Keywords: to right, to left, to bottom right
Angles: 45deg, 90deg, 180deg

background: linear-gradient(to right, blue, purple);
", + "task": "Make the gradient flow from left to right using to right.", + "previewHTML": "", + "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 to right to set the direction" + } + ] + }, + { + "id": "gradients-3", + "title": "Radial Gradient", + "description": "The radial-gradient() function creates a gradient that radiates from a center point outward in a circular or elliptical pattern.

background: radial-gradient(circle, white, steelblue);

Add circle for a perfect circular gradient.", + "task": "Create a radial gradient from white to steelblue.", + "previewHTML": "
", + "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 radial-gradient()" + }, + { + "type": "contains", + "value": "white", + "message": "Start with white" + }, + { + "type": "contains", + "value": "steelblue", + "message": "End with steelblue" + } + ] + } + ] +} diff --git a/lessons/11-filters.json b/lessons/11-filters.json new file mode 100644 index 0000000..11fdd7b --- /dev/null +++ b/lessons/11-filters.json @@ -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 filter property applies visual effects to elements. The blur() function creates a Gaussian blur effect.

filter: blur(4px);

Higher values create more blur. This is great for backgrounds or creating depth.", + "task": "Blur the background image using filter: blur(4px).", + "previewHTML": "

Welcome

", + "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 filter: blur(4px)" + } + ] + }, + { + "id": "filters-2", + "title": "Grayscale Filter", + "description": "The grayscale() function removes color from an element. Use values from 0% (full color) to 100% (fully grayscale).

filter: grayscale(100%);

Great for hover effects or disabled states.", + "task": "Make the image grayscale with filter: grayscale(100%).", + "previewHTML": "
", + "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 grayscale() filter" + }, + { + "type": "contains", + "value": "100%", + "message": "Set to 100% for full grayscale" + } + ] + }, + { + "id": "filters-3", + "title": "Brightness Filter", + "description": "The brightness() function adjusts how bright an element appears. Values below 100% darken, above 100% brighten.

filter: brightness(150%);
", + "task": "Brighten the card with filter: brightness(120%).", + "previewHTML": "
Featured
", + "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 brightness() filter" + }, + { + "type": "contains", + "value": "120%", + "message": "Set to 120%" + } + ] + }, + { + "id": "filters-4", + "title": "Drop Shadow", + "description": "The drop-shadow() filter creates a shadow that follows the shape of the element, including transparency. Unlike box-shadow, it works on images with transparent backgrounds.

filter: drop-shadow(2px 4px 6px black);
", + "task": "Add a drop shadow with filter: drop-shadow(4px 4px 8px gray).", + "previewHTML": "
", + "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 drop-shadow() filter" + }, + { + "type": "contains", + "value": "4px 4px 8px", + "message": "Set shadow offset and blur" + } + ] + } + ] +} diff --git a/lessons/12-positioning.json b/lessons/12-positioning.json new file mode 100644 index 0000000..dcfb0af --- /dev/null +++ b/lessons/12-positioning.json @@ -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 position property controls how elements are placed. relative keeps the element in normal flow but allows you to offset it with top, right, bottom, left.

.box {\n  position: relative;\n  top: 10px;\n}
", + "task": "Make the badge position relative so we can offset it.", + "previewHTML": "
NEW

Product

", + "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 position: relative" + } + ] + }, + { + "id": "position-2", + "title": "Offset Properties", + "description": "With position: relative, use offset properties to nudge the element from its original position:

top - pushes down from top
left - pushes right from left

Negative values move in the opposite direction.", + "task": "Move the badge up with top: -8px.", + "previewHTML": "
NEW

Product

", + "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 top: -8px" + } + ] + }, + { + "id": "position-3", + "title": "Absolute Position", + "description": "position: absolute removes the element from normal flow and positions it relative to its nearest positioned ancestor (or the viewport if none exists).

Always set a parent to position: relative to contain absolute children.", + "task": "Position the close button absolutely.", + "previewHTML": "

Modal

Content here

", + "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 position: absolute" + } + ] + }, + { + "id": "position-4", + "title": "Placing Absolute Elements", + "description": "Combine position: absolute with offset properties to place elements precisely.

.close {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n}
", + "task": "Move the close button to the top right corner with top: 8px and right: 8px.", + "previewHTML": "

Modal

Content here

", + "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 top: 8px" + }, + { + "type": "property_value", + "value": { "property": "right", "expected": "8px" }, + "message": "Set right: 8px" + } + ] + } + ] +} diff --git a/lessons/13-pseudo-elements.json b/lessons/13-pseudo-elements.json new file mode 100644 index 0000000..02da42d --- /dev/null +++ b/lessons/13-pseudo-elements.json @@ -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. ::before creates a virtual element as the first child.

It requires the content property to display anything (even if empty).

.item::before {\n  content: \"→ \";\n}
", + "task": "Add a bullet before each list item using ::before with content: \"• \".", + "previewHTML": "", + "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 content property" + }, + { + "type": "contains", + "value": "•", + "message": "Add a bullet character " + } + ] + }, + { + "id": "pseudo-2", + "title": "Styling ::before", + "description": "Pseudo-elements can be styled like any element. Add color, size, margins, and more.

.item::before {\n  content: \"★\";\n  color: gold;\n  margin-right: 8px;\n}
", + "task": "Style the bullet with color: coral.", + "previewHTML": "", + "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 color: coral" + } + ] + }, + { + "id": "pseudo-3", + "title": "The ::after Element", + "description": "::after works like ::before but inserts content as the last child. Common uses include badges, icons, or decorative elements.

.new::after {\n  content: \" ✓\";\n  color: green;\n}
", + "task": "Add a checkmark after completed items with content: \" ✓\".", + "previewHTML": "", + "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 content property" + }, + { + "type": "contains", + "value": "✓", + "message": "Add a checkmark " + } + ] + }, + { + "id": "pseudo-4", + "title": "Decorative Lines", + "description": "Pseudo-elements with content: \"\" can create decorative shapes when combined with width, height, and background.

.title::after {\n  content: \"\";\n  display: block;\n  width: 50px;\n  height: 3px;\n  background: coral;\n}
", + "task": "Create an underline decoration with width: 40px, height: 3px, and background: steelblue.", + "previewHTML": "

About Us

We build great things.

", + "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 width: 40px" + }, + { + "type": "property_value", + "value": { "property": "height", "expected": "3px" }, + "message": "Set height: 3px" + }, + { + "type": "property_value", + "value": { "property": "background", "expected": "steelblue" }, + "message": "Set background: steelblue" + } + ] + } + ] +} diff --git a/lessons/30-html-tables.json b/lessons/30-html-tables.json index 9fd3c63..21f2e2a 100644 --- a/lessons/30-html-tables.json +++ b/lessons/30-html-tables.json @@ -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:

<thead> — header section
<tbody> — main content
<tfoot> — footer (totals, summaries)", + "task": "Wrap the header row in <thead> and data rows in <tbody>.", + "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": "\n \n \n \n \n \n \n \n \n \n \n \n \n
NameScore
Alice95
Bob87
", + "solution": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
NameScore
Alice95
Bob87
", + "previewContainer": "preview-area", + "validations": [ + { + "type": "element_exists", + "value": "thead", + "message": "Add a <thead> section for the header" + }, + { + "type": "element_exists", + "value": "tbody", + "message": "Add a <tbody> section for the data" + } + ] + }, + { + "id": "table-colspan", + "title": "Spanning Columns", + "description": "The colspan attribute lets a cell span multiple columns. This is useful for headers that group multiple columns or footer totals.

<td colspan=\"2\">...</td>
", + "task": "Add a footer row that spans both columns using colspan=\"2\".", + "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": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ItemPrice
Coffee$4
Cake$6
", + "solution": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ItemPrice
Coffee$4
Cake$6
Total: $10
", + "previewContainer": "preview-area", + "validations": [ + { + "type": "element_exists", + "value": "tfoot", + "message": "Add a <tfoot> section" + }, + { + "type": "contains", + "value": "colspan", + "message": "Use colspan to span columns" + } + ] } ] } diff --git a/lessons/33-html-semantic.json b/lessons/33-html-semantic.json new file mode 100644 index 0000000..df148f8 --- /dev/null +++ b/lessons/33-html-semantic.json @@ -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
Element", + "description": "The <article> element represents self-contained content that could be distributed independently, like a blog post, news article, or comment.

<article>\n  <h2>Article Title</h2>\n  <p>Article content...</p>\n</article>
", + "task": "Wrap the blog post content in an <article> 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": "

My First Post

\n

This is a blog post about learning HTML.

", + "codeSuffix": "", + "solution": "
\n

My First Post

\n

This is a blog post about learning HTML.

\n
", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "
", + "message": "Add an opening <article> tag" + }, + { + "type": "contains", + "value": "
", + "message": "Add a closing </article> tag" + } + ] + }, + { + "id": "semantic-2", + "title": "The
Element", + "description": "The <section> element represents a thematic grouping of content, typically with a heading. Use it to divide a page into logical sections.

<section>\n  <h2>Features</h2>\n  <p>Our product features...</p>\n</section>
", + "task": "Wrap the features content in a <section> 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": "

Features

\n", + "codeSuffix": "", + "solution": "
\n

Features

\n
    \n
  • Fast performance
  • \n
  • Easy to use
  • \n
\n
", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "
", + "message": "Add an opening <section> tag" + }, + { + "type": "contains", + "value": "
", + "message": "Add a closing </section> tag" + } + ] + }, + { + "id": "semantic-3", + "title": "The
@@ -173,37 +175,54 @@

Coming Soon

- 🔄 -

Cloud Sync

-

Sync your progress across all devices. Start on desktop, continue on tablet.

-
-
- 🏆 + + +

Achievements

Earn badges as you master new skills. Track your learning milestones.

- + + +

JavaScript

Interactive JavaScript lessons with live code execution and DOM manipulation.

- 🧩 + + +

Frameworks

React, Vue, and Svelte basics. Build real components step by step.

+
+ + + +

Code Challenges

+

Test your skills with timed puzzles. Compete on leaderboards and earn ranks.

+
+
+
+

Want to know when new features launch?

+
+ + +
+

Max once a week. Unsubscribe anytime via mail@codecrispi.es

+
-

+

Best on desktop or tablet (landscape). Mobile works, but larger screens make coding easier.

Start Learning Today

- Begin Your Journey + Let's get crispy!

Free and open source. No account required. Progress saved locally.

@@ -237,6 +256,11 @@ @@ -289,6 +313,11 @@ @@ -337,6 +366,11 @@ @@ -374,7 +408,7 @@
- + + + +
+ + +
+

Delete Account

+ +
+
+

Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.

+ +
+ + +
+
+
+
@@ -614,6 +685,136 @@
+ + + +
+

Privacy Policy

+ +
+ +
+ + + +
+

Imprint

+ +
+ +
+ + + +
+

Log In

+ +
+
+ +
+
+ + +
+
+ + +
+ + +
+ + + + + + + + +
+
or continue with
+
+ + +
+
+ + + +
+
diff --git a/src/main.css b/src/main.css index 7423163..b4716ab 100644 --- a/src/main.css +++ b/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 { diff --git a/src/supabase.js b/src/supabase.js new file mode 100644 index 0000000..a231968 --- /dev/null +++ b/src/supabase.js @@ -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 }; + }, +}; diff --git a/supabase-setup.sql b/supabase-setup.sql new file mode 100644 index 0000000..0134774 --- /dev/null +++ b/supabase-setup.sql @@ -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; +$$; diff --git a/vite.config.js b/vite.config.js index 23a2c56..8adbdeb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,6 +3,7 @@ import { defineConfig } from "vite"; export default defineConfig((env) => ({ base: "/", root: "./src", + envDir: "..", publicDir: "../public", build: { outDir: "../dist",