wip: courses

feat/course
Arik Chakma 7 days ago
parent 5e0ff6c780
commit bacc972713
  1. 11
      package.json
  2. 256
      pnpm-lock.yaml
  3. 42
      src/components/Course/ChallengeView.tsx
  4. 102
      src/components/Course/Chapter.tsx
  5. 13
      src/components/Course/CourseLayout.tsx
  6. 81
      src/components/Course/CourseSidebar.tsx
  7. 25
      src/components/Course/LessonView.tsx
  8. 235
      src/components/Course/QuizView.tsx
  9. 42
      src/components/Resizable.tsx
  10. 210
      src/components/SqlCodeEditor/SqlCodeEditor.tsx
  11. 115
      src/components/SqlCodeEditor/SqlTableResult.tsx
  12. 77
      src/components/SqlCodeEditor/sql-code-editor-theme.ts
  13. 90
      src/components/SqlCodeEditor/use-sql-editor.ts
  14. 20
      src/components/SqlCodeEditor/use-sqlite.ts
  15. 34
      src/data/courses/sql/chapters/introduction/exercises/challenge-1.md
  16. 5
      src/data/courses/sql/chapters/introduction/introduction.md
  17. 9
      src/data/courses/sql/chapters/introduction/lessons/intro-to-sql.md
  18. 5
      src/data/courses/sql/sql.md
  19. 242
      src/lib/course.ts
  20. 23
      src/pages/learn-sql/index.astro

@ -33,7 +33,13 @@
"@astrojs/react": "^3.6.2",
"@astrojs/sitemap": "^3.1.6",
"@astrojs/tailwind": "^5.1.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-sql": "^6.8.0",
"@codemirror/language": "^6.10.3",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@fingerprintjs/fingerprintjs": "^4.4.3",
"@lezer/highlight": "^1.2.1",
"@nanostores/react": "^0.7.2",
"@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2",
@ -41,6 +47,7 @@
"@types/react-dom": "^18.3.0",
"astro": "^4.15.4",
"clsx": "^2.1.1",
"codemirror": "^6.0.1",
"dayjs": "^1.11.12",
"dom-to-image": "^2.6.0",
"dracula-prism": "^2.1.16",
@ -61,6 +68,7 @@
"react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.3.1",
"react-resizable-panels": "^2.1.4",
"react-tooltip": "^5.27.1",
"reactflow": "^11.11.4",
"rehype-external-links": "^3.0.0",
@ -70,6 +78,8 @@
"satori-html": "^0.3.2",
"sharp": "^0.33.4",
"slugify": "^1.6.6",
"sql-formatter": "^15.4.3",
"sql.js": "^1.11.0",
"tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.7",
"turndown": "^7.2.0",
@ -85,6 +95,7 @@
"@types/prismjs": "^1.26.4",
"@types/react-calendar-heatmap": "^1.6.7",
"@types/react-slick": "^0.23.13",
"@types/sql.js": "^1.4.9",
"@types/turndown": "^5.0.5",
"csv-parser": "^3.0.0",
"gh-pages": "^6.1.1",

@ -20,9 +20,27 @@ importers:
'@astrojs/tailwind':
specifier: ^5.1.0
version: 5.1.1(astro@4.15.8(@types/node@18.19.50)(rollup@4.22.4)(typescript@5.6.2))(tailwindcss@3.4.13)
'@codemirror/commands':
specifier: ^6.7.0
version: 6.7.0
'@codemirror/lang-sql':
specifier: ^6.8.0
version: 6.8.0(@codemirror/view@6.34.1)
'@codemirror/language':
specifier: ^6.10.3
version: 6.10.3
'@codemirror/state':
specifier: ^6.4.1
version: 6.4.1
'@codemirror/view':
specifier: ^6.34.1
version: 6.34.1
'@fingerprintjs/fingerprintjs':
specifier: ^4.4.3
version: 4.5.0
'@lezer/highlight':
specifier: ^1.2.1
version: 1.2.1
'@nanostores/react':
specifier: ^0.7.2
version: 0.7.3(nanostores@0.10.3)(react@18.3.1)
@ -44,6 +62,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
codemirror:
specifier: ^6.0.1
version: 6.0.1(@lezer/common@1.2.2)
dayjs:
specifier: ^1.11.12
version: 1.11.13
@ -104,6 +125,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.4
version: 2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-tooltip:
specifier: ^5.27.1
version: 5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -131,6 +155,12 @@ importers:
slugify:
specifier: ^1.6.6
version: 1.6.6
sql-formatter:
specifier: ^15.4.3
version: 15.4.3
sql.js:
specifier: ^1.11.0
version: 1.11.0
tailwind-merge:
specifier: ^2.4.0
version: 2.5.2
@ -171,6 +201,9 @@ importers:
'@types/react-slick':
specifier: ^0.23.13
version: 0.23.13
'@types/sql.js':
specifier: ^1.4.9
version: 1.4.9
'@types/turndown':
specifier: ^5.0.5
version: 5.0.5
@ -355,6 +388,35 @@ packages:
resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.18.1':
resolution: {integrity: sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
'@codemirror/commands@6.7.0':
resolution: {integrity: sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw==}
'@codemirror/lang-sql@6.8.0':
resolution: {integrity: sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==}
'@codemirror/language@6.10.3':
resolution: {integrity: sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==}
'@codemirror/lint@6.8.2':
resolution: {integrity: sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==}
'@codemirror/search@6.5.6':
resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
'@codemirror/state@6.4.1':
resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==}
'@codemirror/view@6.34.1':
resolution: {integrity: sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==}
'@emnapi/core@1.2.0':
resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==}
@ -785,6 +847,15 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@lezer/common@1.2.2':
resolution: {integrity: sha512-Z+R3hN6kXbgBWAuejUNPihylAL1Z5CaFqnIe0nTX8Ej+XlIy3EGtXxn6WtLMO+os2hRkQvm2yvaGMYliUzlJaw==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
@ -1240,6 +1311,9 @@ packages:
'@types/dom-to-image@2.6.7':
resolution: {integrity: sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==}
'@types/emscripten@1.39.13':
resolution: {integrity: sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw==}
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@ -1297,6 +1371,9 @@ packages:
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/sql.js@1.4.9':
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
'@types/turndown@5.0.5':
resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==}
@ -1504,6 +1581,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codemirror@6.0.1:
resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@ -1535,6 +1615,9 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@ -1555,6 +1638,9 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@ -1690,6 +1776,9 @@ packages:
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
engines: {node: '>=0.3.1'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@ -1893,6 +1982,10 @@ packages:
resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==}
engines: {node: '>=18'}
get-stdin@8.0.0:
resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==}
engines: {node: '>=10'}
get-tsconfig@4.8.1:
resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==}
@ -2378,6 +2471,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
moo@0.5.2:
resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==}
mrmime@2.0.0:
resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==}
engines: {node: '>=10'}
@ -2405,6 +2501,10 @@ packages:
resolution: {integrity: sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g==}
engines: {node: ^18.0.0 || >=20.0.0}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
neotraverse@0.6.18:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
@ -2718,6 +2818,13 @@ packages:
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@ -2745,6 +2852,12 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
react-resizable-panels@2.1.4:
resolution: {integrity: sha512-kzue8lsoSBdyyd2IfXLQMMhNujOxRoGVus+63K95fQqleGxTfvgYLTzbwYMOODeAHqnkjb3WV/Ks7f5+gDYZuQ==}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
react-tooltip@5.28.0:
resolution: {integrity: sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==}
peerDependencies:
@ -2813,6 +2926,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
retext-latin@4.0.0:
resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==}
@ -2926,6 +3043,13 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sql-formatter@15.4.3:
resolution: {integrity: sha512-RnYhnCojj9jlaVr04Vol2E0aUnZuunUq3gArnzwagsyV5mBXeX6r1rRfHdDzyDkO1NcsPiHCs9ik00Kf9AUMfQ==}
hasBin: true
sql.js@1.11.0:
resolution: {integrity: sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@ -2975,6 +3099,9 @@ packages:
resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==}
engines: {node: '>=0.10.0'}
style-mod@4.1.2:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
sucrase@3.35.0:
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
engines: {node: '>=16 || 14 >=14.17'}
@ -3181,6 +3308,9 @@ packages:
vite:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@ -3508,6 +3638,60 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.7
to-fast-properties: 2.0.0
'@codemirror/autocomplete@6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2)':
dependencies:
'@codemirror/language': 6.10.3
'@codemirror/state': 6.4.1
'@codemirror/view': 6.34.1
'@lezer/common': 1.2.2
'@codemirror/commands@6.7.0':
dependencies:
'@codemirror/language': 6.10.3
'@codemirror/state': 6.4.1
'@codemirror/view': 6.34.1
'@lezer/common': 1.2.2
'@codemirror/lang-sql@6.8.0(@codemirror/view@6.34.1)':
dependencies:
'@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2)
'@codemirror/language': 6.10.3
'@codemirror/state': 6.4.1
'@lezer/common': 1.2.2
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
transitivePeerDependencies:
- '@codemirror/view'
'@codemirror/language@6.10.3':
dependencies:
'@codemirror/state': 6.4.1
'@codemirror/view': 6.34.1
'@lezer/common': 1.2.2
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
style-mod: 4.1.2
'@codemirror/lint@6.8.2':
dependencies:
'@codemirror/state': 6.4.1
'@codemirror/view': 6.34.1
crelt: 1.0.6
'@codemirror/search@6.5.6':
dependencies:
'@codemirror/state': 6.4.1
'@codemirror/view': 6.34.1
crelt: 1.0.6
'@codemirror/state@6.4.1': {}
'@codemirror/view@6.34.1':
dependencies:
'@codemirror/state': 6.4.1
style-mod: 4.1.2
w3c-keyname: 2.2.8
'@emnapi/core@1.2.0':
dependencies:
'@emnapi/wasi-threads': 1.0.1
@ -3781,6 +3965,16 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@lezer/common@1.2.2': {}
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.2
'@lezer/lr@1.4.2':
dependencies:
'@lezer/common': 1.2.2
'@mixmark-io/domino@2.2.0': {}
'@nanostores/react@0.7.3(nanostores@0.10.3)(react@18.3.1)':
@ -4245,6 +4439,8 @@ snapshots:
'@types/dom-to-image@2.6.7': {}
'@types/emscripten@1.39.13': {}
'@types/estree@1.0.5': {}
'@types/estree@1.0.6': {}
@ -4305,6 +4501,11 @@ snapshots:
dependencies:
'@types/node': 17.0.45
'@types/sql.js@1.4.9':
dependencies:
'@types/emscripten': 1.39.13
'@types/node': 18.19.50
'@types/turndown@5.0.5': {}
'@types/unist@3.0.3': {}
@ -4568,6 +4769,18 @@ snapshots:
clsx@2.1.1: {}
codemirror@6.0.1(@lezer/common@1.2.2):
dependencies:
'@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2)
'@codemirror/commands': 6.7.0
'@codemirror/language': 6.10.3
'@codemirror/lint': 6.8.2
'@codemirror/search': 6.5.6
'@codemirror/state': 6.4.1
'@codemirror/view': 6.34.1
transitivePeerDependencies:
- '@lezer/common'
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@ -4598,6 +4811,8 @@ snapshots:
commander@11.1.0: {}
commander@2.20.3: {}
commander@4.1.1: {}
common-ancestor-path@1.0.1: {}
@ -4610,6 +4825,8 @@ snapshots:
cookie@0.6.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
@ -4720,6 +4937,8 @@ snapshots:
diff@5.2.0: {}
discontinuous-range@1.0.0: {}
dlv@1.1.3: {}
dom-serializer@2.0.0:
@ -4935,6 +5154,8 @@ snapshots:
get-east-asian-width@1.2.0: {}
get-stdin@8.0.0: {}
get-tsconfig@4.8.1:
dependencies:
resolve-pkg-maps: 1.0.0
@ -5632,6 +5853,8 @@ snapshots:
minipass@7.1.2: {}
moo@0.5.2: {}
mrmime@2.0.0: {}
ms@2.0.0: {}
@ -5650,6 +5873,13 @@ snapshots:
nanostores@0.10.3: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.2
railroad-diagrams: 1.0.0
randexp: 0.4.6
neotraverse@0.6.18: {}
nlcst-to-string@4.0.0:
@ -5894,6 +6124,13 @@ snapshots:
dependencies:
inherits: 2.0.4
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
range-parser@1.2.1: {}
react-calendar-heatmap@1.9.0(react@18.3.1):
@ -5917,6 +6154,11 @@ snapshots:
react-refresh@0.14.2: {}
react-resizable-panels@2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-tooltip@5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@floating-ui/dom': 1.6.11
@ -6040,6 +6282,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
ret@0.1.15: {}
retext-latin@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@ -6219,6 +6463,14 @@ snapshots:
sprintf-js@1.0.3: {}
sql-formatter@15.4.3:
dependencies:
argparse: 2.0.1
get-stdin: 8.0.0
nearley: 2.20.1
sql.js@1.11.0: {}
statuses@2.0.1: {}
stdin-discarder@0.2.2: {}
@ -6266,6 +6518,8 @@ snapshots:
dependencies:
escape-string-regexp: 1.0.5
style-mod@4.1.2: {}
sucrase@3.35.0:
dependencies:
'@jridgewell/gen-mapping': 0.3.5
@ -6475,6 +6729,8 @@ snapshots:
optionalDependencies:
vite: 5.4.7(@types/node@18.19.50)
w3c-keyname@2.2.8: {}
web-namespaces@2.0.1: {}
web-streams-polyfill@4.0.0-beta.3: {}

@ -0,0 +1,42 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '../Resizable';
import { CourseSidebar } from './CourseSidebar';
import { CourseLayout } from './CourseLayout';
import {
SqlCodeEditor,
type SqlCodeEditorProps,
} from '../SqlCodeEditor/SqlCodeEditor';
import type { ReactNode } from 'react';
type ChallengeViewProps = SqlCodeEditorProps & {
children: ReactNode;
};
export function ChallengeView(props: ChallengeViewProps) {
const { children, ...sqlCodeEditorProps } = props;
return (
<CourseLayout>
<CourseSidebar />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={60} minSize={20}>
<div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="mx-auto max-w-xl p-4">{children}</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle={true} />
<ResizablePanel defaultSize={40} minSize={20}>
<SqlCodeEditor {...sqlCodeEditorProps} />
</ResizablePanel>
</ResizablePanelGroup>
</CourseLayout>
);
}

@ -0,0 +1,102 @@
import { Check } from 'lucide-react';
import { cn } from '../../lib/classname';
export type Lesson = {
slug: string;
index: number;
title: string;
};
export type Quiz = {
slug: string;
index: number;
title: string;
};
export type Challenge = {
slug: string;
index: number;
title: string;
};
export type Chapter = {
index: number;
slug: string;
title: string;
lessons: Lesson[];
exercises: (Quiz | Challenge)[];
};
type ChapterProps = Chapter & {
isActive?: boolean;
isCompleted?: boolean;
};
export function Chapter(props: ChapterProps) {
const { index, title, lessons, exercises, isActive = false } = props;
return (
<div>
<button
className={cn(
'flex w-full items-center gap-2 border-b border-zinc-800 p-2 text-sm',
isActive && 'bg-zinc-300 text-zinc-900',
)}
>
<div className="flex size-5 items-center justify-center rounded-full bg-zinc-700 text-xs text-white">
{index}
</div>
<span>{title}</span>
</button>
{isActive && (
<div className="flex flex-col border-b border-zinc-800">
<div>
{lessons.map((lesson) => (
<Lesson key={lesson.slug} {...lesson} isCompleted={false} />
))}
</div>
<div className="relative">
<label className="relative z-10 my-2 ml-2 block max-w-max rounded-md bg-zinc-800 p-1 px-2 text-xs">
Exercises
</label>
<span className="absolute left-[17px] top-0 h-full w-0.5 bg-zinc-700"></span>
</div>
<div>
{exercises.map((exercise) => (
<Lesson key={exercise.slug} {...exercise} isCompleted={false} />
))}
</div>
</div>
)}
</div>
);
}
type LessonProps = (Lesson | Quiz | Challenge) & {
isActive?: boolean;
isCompleted?: boolean;
};
export function Lesson(props: LessonProps) {
const { title, isCompleted, isActive } = props;
return (
<a
className={cn(
'relative flex w-full items-center gap-2 p-2 text-sm text-zinc-600',
isActive && 'bg-zinc-800 text-white',
)}
>
<div className="relative z-10 flex size-5 items-center justify-center rounded-full bg-zinc-700 text-xs text-white">
{isCompleted && <Check className="h-4 w-4" />}
</div>
<span>{title}</span>
<span className="absolute left-[17px] top-0 h-full w-0.5 bg-zinc-700"></span>
</a>
);
}

@ -0,0 +1,13 @@
type CourseLayoutProps = {
children: React.ReactNode;
};
export function CourseLayout(props: CourseLayoutProps) {
const { children } = props;
return (
<section className="grid h-screen grid-cols-[240px_1fr] overflow-hidden bg-zinc-900 text-zinc-50">
{children}
</section>
);
}

@ -0,0 +1,81 @@
import { Chapter } from './Chapter';
export function CourseSidebar() {
return (
<aside className="border-r border-zinc-800">
<div className="border-b border-zinc-800 p-4">
<h2 className="text-lg font-semibold">Learn SQL</h2>
<div className="mt-4">
<span>5% Completed</span>
<div className="mt-2 h-1 w-full bg-zinc-800"></div>
</div>
</div>
<div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<Chapter
slug="1"
index={1}
title="DDL Queries"
lessons={[
{
slug: '1',
index: 1,
title: 'Creating Tables',
},
{
slug: '2',
index: 2,
title: 'Altering Tables',
},
]}
exercises={[
{
slug: '3',
index: 1,
title: 'Quiz 1',
},
{
slug: '4',
index: 2,
title: 'Challenge 1',
},
]}
/>
<Chapter
slug="2"
index={2}
title="DML Queries"
isActive={true}
lessons={[
{
slug: '5',
index: 1,
title: 'Inserting Data',
},
{
slug: '6',
index: 2,
title: 'Updating Data',
},
]}
exercises={[
{
slug: '7',
index: 1,
title: 'Quiz 2',
},
{
slug: '8',
index: 2,
title: 'Challenge 2',
},
]}
/>
</div>
</div>
</aside>
);
}

@ -0,0 +1,25 @@
import { useState, type ReactNode } from 'react';
import { CourseSidebar } from './CourseSidebar';
import { CourseLayout } from './CourseLayout';
import { Circle, CircleCheck, CircleX } from 'lucide-react';
import { cn } from '../../lib/classname';
type LessonViewProps = {
children: ReactNode;
};
export function LessonView(props: LessonViewProps) {
const { children } = props;
return (
<CourseLayout>
<CourseSidebar />
<div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="mx-auto max-w-xl p-4 py-10">{children}</div>
</div>
</div>
</CourseLayout>
);
}

@ -0,0 +1,235 @@
import { useState } from 'react';
import { CourseSidebar } from './CourseSidebar';
import { CourseLayout } from './CourseLayout';
import { Circle, CircleCheck, CircleX } from 'lucide-react';
import { cn } from '../../lib/classname';
const questions = [
{
id: 1,
title:
'Which of the following SQL clauses is used to filter results after the GROUP BY clause?',
options: [
{ id: 1, text: 'WHERE' },
{ id: 2, text: 'HAVING', isCorrectOption: true },
{ id: 3, text: 'GROUP BY' },
{ id: 4, text: 'ORDER BY' },
],
},
{
id: 2,
title:
'Which SQL function is used to return the first non-null expression?',
options: [
{ id: 1, text: 'COALESCE', isCorrectOption: true },
{ id: 2, text: 'IFNULL' },
{ id: 3, text: 'NULLIF' },
{ id: 4, text: 'NVL' },
],
},
{
id: 3,
title: 'What is the purpose of an SQL CTE (Common Table Expression)?',
options: [
{
id: 1,
text: 'To create temporary tables that last for the duration of a query',
isCorrectOption: true,
},
{ id: 2, text: 'To define reusable views' },
{ id: 3, text: 'To encapsulate subqueries' },
{ id: 4, text: 'To optimize the execution of queries' },
],
},
{
id: 4,
title:
'In an SQL window function, which clause defines the subset of rows to apply the function on?',
options: [
{ id: 1, text: 'ORDER BY' },
{ id: 2, text: 'PARTITION BY', isCorrectOption: true },
{ id: 3, text: 'GROUP BY' },
{ id: 4, text: 'DISTINCT' },
],
},
{
id: 5,
title:
'Which SQL join returns all rows when there is a match in either of the tables?',
options: [
{ id: 1, text: 'INNER JOIN' },
{ id: 2, text: 'LEFT JOIN' },
{ id: 3, text: 'RIGHT JOIN' },
{ id: 4, text: 'FULL OUTER JOIN', isCorrectOption: true },
],
},
];
export function QuizView() {
const [selectedOptions, setSelectedOptions] = useState<
Record<number, number | undefined>
>({});
const [isSubmitted, setIsSubmitted] = useState(false);
const isAllAnswered =
Object.keys(selectedOptions).length === questions.length;
const correctAnswerCount = questions.filter((question) => {
const selectedOptionId = selectedOptions?.[question.id];
const correctAnswerId = question.options.find(
(option) => option.isCorrectOption,
)?.id;
return selectedOptionId === correctAnswerId;
}).length;
return (
<CourseLayout>
<CourseSidebar />
<div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="mx-auto max-w-xl p-4 py-10">
<h3 className="mb-10 text-lg font-semibold">
SQL Quiz: Intermediate
</h3>
<div className="flex flex-col gap-3">
{questions.map((question) => {
return (
<QuizItem
key={question.id}
id={question.id}
title={question.title}
disabled={status === 'submitted'}
options={question.options.map((option) => {
const selectedOptionId = selectedOptions?.[question.id];
let optionStatus: QuizOptionStatus = 'default';
if (option.isCorrectOption && isSubmitted) {
optionStatus = 'correct';
} else if (selectedOptionId === option.id) {
optionStatus = isSubmitted ? 'wrong' : 'selected';
}
return {
...option,
status: optionStatus,
};
})}
onOptionSelectChange={(id, optionId) => {
setSelectedOptions((prev) => ({
...prev,
[id]: optionId,
}));
}}
selectedOptionId={selectedOptions?.[question.id]}
/>
);
})}
</div>
<div className="mt-8 flex items-center justify-end">
<button
className="rounded-xl border border-zinc-700 bg-zinc-800 p-2 px-4 text-sm font-medium text-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitted || !isAllAnswered}
onClick={() => {
setIsSubmitted(true);
}}
>
Submit my Answers
</button>
</div>
{isSubmitted && (
<div className="mt-8 flex items-center justify-between gap-2 rounded-xl border border-zinc-800 p-4">
<span>
You got {correctAnswerCount} out of {questions.length}{' '}
questions right
</span>
<a className="disabled:cusror-not-allowed rounded-xl border border-zinc-700 bg-zinc-800 p-2 px-4 text-sm font-medium text-white focus:outline-none">
Move to Next Lesson
</a>
</div>
)}
</div>
</div>
</div>
</CourseLayout>
);
}
type QuizItemProps = {
id: number;
title: string;
options: QuizOptionProps[];
disabled?: boolean;
selectedOptionId?: number;
onOptionSelectChange?: (id: number, optionId: number) => void;
};
export function QuizItem(props: QuizItemProps) {
const { id, title, options, onOptionSelectChange, disabled } = props;
return (
<div className="rounded-2xl bg-zinc-800 p-4">
<h3 className="mx-2 text-balance text-lg font-medium">{title}</h3>
<div className="mt-4 flex flex-col gap-1">
{options.map((option, index) => {
return (
<QuizOption
key={index}
id={option.id}
text={option.text}
status={option.status}
onSelect={() => onOptionSelectChange?.(id, option.id)}
disabled={disabled}
/>
);
})}
</div>
</div>
);
}
type QuizOptionStatus = 'selected' | 'wrong' | 'correct' | 'default';
type QuizOptionProps = {
id: number;
text: string;
isCorrectOption?: boolean;
status?: QuizOptionStatus;
disabled?: boolean;
onSelect?: () => void;
};
export function QuizOption(props: QuizOptionProps) {
const { text, status = 'default', onSelect, disabled } = props;
return (
<button
onClick={onSelect}
className={cn(
'flex items-start gap-2 rounded-xl p-2 text-sm disabled:cursor-not-allowed',
status === 'selected' && 'ring-1 ring-zinc-500',
status === 'wrong' && 'text-red-500 ring-1 ring-red-500',
status === 'correct' && 'text-green-500 ring-1 ring-green-500',
status === 'default' && 'hover:bg-zinc-700',
)}
disabled={disabled}
>
<span className="mt-0.5">
{status === 'wrong' && <CircleX className="size-4" />}
{status === 'correct' && <CircleCheck className="size-4" />}
{(status === 'selected' || status === 'default') && (
<Circle className="size-4" />
)}
</span>
<p>{text}</p>
</button>
);
}

@ -0,0 +1,42 @@
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '../lib/classname';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'focus-visible:ring-ring relative flex w-px items-center justify-center bg-zinc-800 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border border-zinc-800 bg-zinc-800">
<GripVertical className="h-2.5 w-2.5 text-zinc-500" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

@ -0,0 +1,210 @@
import { useRef, useState } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '../Resizable';
import { SqlTableResult } from './SqlTableResult';
import { type QueryExecResult } from 'sql.js';
import { useSqlEditor } from './use-sql-editor';
import { sql } from '@codemirror/lang-sql';
import { Prec } from '@codemirror/state';
import { keymap } from '@codemirror/view';
import { type LucideIcon, Play, WandSparkles } from 'lucide-react';
import { useSqlite } from './use-sqlite';
import { cn } from '../../lib/classname';
export type SqlCodeEditorProps = {
defaultValue?: string;
initSteps?: string[];
expectedResults?: QueryExecResult[];
};
export function SqlCodeEditor(props: SqlCodeEditorProps) {
const { defaultValue, initSteps = [], expectedResults } = props;
const editorRef = useRef<HTMLDivElement>(null);
const [queryResult, setQueryResult] = useState<QueryExecResult[] | null>(
null,
);
const [queryError, setQueryError] = useState<string | undefined>();
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
const formatQuery = async (query: string) => {
const { format } = await import('sql-formatter');
const formatted = format(query, {
expressionWidth: 40,
language: 'sql',
keywordCase: 'upper',
});
return formatted;
};
const sqlite = useSqlite();
const editor = useSqlEditor({
container: editorRef,
value: defaultValue,
extensions: [
sql({
upperCaseKeywords: true,
schemas: [],
}),
Prec.highest(
keymap.of([
{
key: 'Mod-s',
run: (view) => {
const query = view.state.doc.toString();
formatQuery(query).then((formatted) => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
});
});
return true;
},
},
]),
),
],
});
const handleQuery = (query: string) => {
try {
if (!sqlite) {
throw new Error('SQLite is not initialized');
}
const db = new sqlite.Database();
initSteps.forEach((step) => {
db.exec(step);
});
const results = db.exec(query);
db.close();
return {
results,
error: undefined,
};
} catch (error) {
const err = error as Error;
return {
results: null,
error: err.message,
};
}
};
return (
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={65} className="flex flex-col">
<div className="relative grow">
<div
id="editor"
ref={editorRef}
data-enable-grammarly={false}
className="absolute inset-x-0 inset-y-2 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;] [&>div]:h-full"
></div>
</div>
<div className="flex items-center justify-end gap-1 border-t border-zinc-800 p-2">
<DatabaseActionButton
icon={WandSparkles}
onClick={async () => {
const query = editor?.state?.doc.toString();
if (!query) {
return;
}
const formatted = await formatQuery(query);
editor?.dispatch({
changes: {
from: 0,
to: editor?.state?.doc.length,
insert: formatted,
},
});
}}
/>
<DatabaseActionButton
icon={Play}
onClick={() => {
const query = editor?.state?.doc.toString();
if (!query) {
return;
}
const { results, error } = handleQuery(query);
setQueryResult(results);
setQueryError(error);
setIsSubmitted(false);
}}
/>
<DatabaseActionButton
label="Submit"
onClick={() => {
const query = editor?.state?.doc.toString();
if (!query) {
return;
}
const { results, error } = handleQuery(query);
setQueryResult(results);
setQueryError(error);
setIsSubmitted(true);
}}
className="bg-zinc-800 px-3 text-white"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle={true} />
<ResizablePanel defaultSize={35}>
<SqlTableResult
results={queryResult}
error={queryError}
matchAnswers={isSubmitted}
expectedResults={expectedResults}
onTryAgain={() => {
setQueryResult(null);
setQueryError(undefined);
setIsSubmitted(false);
}}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}
type DatabaseActionButtonProps = {
icon?: LucideIcon;
label?: string;
onClick?: () => void;
className?: string;
};
function DatabaseActionButton(props: DatabaseActionButtonProps) {
const { icon: Icon, label, onClick, className } = props;
return (
<button
className={cn(
'flex h-[30px] min-w-[30px] items-center justify-center gap-1.5 rounded-md p-1 text-sm text-zinc-200 outline-none hover:bg-zinc-800 hover:text-white focus:outline-none',
className,
)}
onClick={onClick}
>
{Icon && <Icon className="h-4 w-4" />}
{label && <span>{label}</span>}
</button>
);
}

@ -0,0 +1,115 @@
import { ServerCrash } from 'lucide-react';
import type { QueryExecResult } from 'sql.js';
type SqlTableResultProps = {
results: QueryExecResult[] | null;
error?: string;
onTryAgain?: () => void;
onNext?: () => void;
matchAnswers?: boolean;
expectedResults?: QueryExecResult[] | null;
};
export function SqlTableResult(props: SqlTableResultProps) {
const {
results,
error,
onNext,
onTryAgain,
expectedResults,
matchAnswers = false,
} = props;
const isCorrectAnswer =
results &&
expectedResults &&
results.length === expectedResults.length &&
results.every((result, index) => {
const expected = expectedResults[index];
return (
result.columns.length === expected.columns.length &&
result.values.length === expected.values.length &&
result.columns.every((column, i) => column === expected.columns[i]) &&
result.values.every((row, i) =>
row.every((cell, j) => cell === expected.values[i][j]),
)
);
});
return (
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
{!isCorrectAnswer && results && expectedResults && matchAnswers && (
<div className="border-b border-zinc-800 p-1 py-8 text-sm">
<p className="text-balance text-center">
Wrong answer! Do you want to try again?
</p>
<div className="mt-2 flex items-center justify-center gap-2">
<button
className="rounded-md bg-zinc-800 px-2 py-0.5 outline-none focus:outline-none"
onClick={onTryAgain}
>
Yes, I want to try again
</button>
<button
className="rounded-md bg-zinc-800 px-2 py-0.5 outline-none focus:outline-none"
onClick={onNext}
>
No, move to next
</button>
</div>
</div>
)}
{error && !results && (
<div className="mt-4 flex flex-col items-center justify-center p-2 text-center text-red-500">
<ServerCrash className="h-8 w-8" />
<span className="mt-4">{error}</span>
</div>
)}
{results && (
<>
{results.length === 0 && (
<p className="p-2 text-sm uppercase text-zinc-200">Ok</p>
)}
{results.map((result, index) => {
return (
<table key={index} className="m-px text-left font-mono">
<thead>
<tr>
{result.columns.map((column, i) => (
<th
key={i}
className="border border-zinc-800 p-1 px-2 font-normal text-white"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{result.values.map((row, index) => (
<tr key={index}>
{row.map((cell, index) => (
<td
key={index}
className="border border-zinc-800 p-1 px-2 font-normal text-white"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
);
})}
</>
)}
</div>
</div>
);
}

@ -0,0 +1,77 @@
import { HighlightStyle } from '@codemirror/language';
import { EditorView } from 'codemirror';
import { tags as t } from '@lezer/highlight';
export const editorDarkTheme = EditorView.theme(
{
// Styling for the editor background and text
'&': {},
// Styling for the editor when focused
'&.cm-focused': {
outline: 'none',
},
// Styling for code blocks
'.cm-content': {},
// Line number styles
'.cm-lineNumbers .cm-gutterElement': {
color: '#757575', // Text color for line numbers
paddingRight: '1em',
},
// Scrollbar styles
'.cm-scroller': {
paddingRight: '12px',
paddingLeft: '12px',
},
'.cm-scroller::-webkit-scrollbar': {},
// Highlight active line
'.cm-activeLine': {
backgroundColor: '#27272a', // Active line background color
},
// Cursor styles
'.cm-cursor': {
borderColor: '#f4f4f5',
},
// Code line styling for inserted and deleted lines
'.code-line.inserted': {
backgroundColor: 'rgba(72, 187, 120, 0.2)', // Green background for inserted lines
},
'.code-line.deleted': {
backgroundColor: 'rgba(239, 68, 68, 0.1)', // Red background for deleted lines
},
// Highlighted line
'.highlight-line': {
borderLeft: '2px solid #EC4899', // Pink border for highlighted line
backgroundColor: 'rgba(229, 231, 235, 0.4)', // Light gray background for highlighted lines
},
'.cm-gutters': {
backgroundColor: '#18181b',
},
'.cm-activeLineGutter': {
backgroundColor: '#27272a',
},
'.cm-line': {
color: '#a1a1aa',
},
'.cm-foldPlaceholder': {
border: 'none',
backgroundColor: 'transparent',
},
},
{
dark: true,
},
);
export const editorDarkHightlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: '#3B82F6' },
{ tag: t.operator, color: '#F59E0B' },
{ tag: t.variableName, color: '#D946EF' },
{ tag: t.number, color: '#10B981' },
{ tag: t.string, color: '#A855F7' },
{ tag: t.punctuation, color: '#a1a1aa' },
{ tag: t.typeName, color: '#D946EF' },
{ tag: t.attributeName, color: '#A855F7' },
{ tag: t.name, color: '#f4f4f5' },
{ tag: t.comment, color: '#71717a' },
{ tag: t.paren, color: '#a1a1aa' },
]);

@ -0,0 +1,90 @@
import { type RefObject, useEffect, useState } from 'react';
import { Annotation, type Extension } from '@codemirror/state';
import { EditorView, keymap, ViewUpdate } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from '@codemirror/commands';
import { syntaxHighlighting } from '@codemirror/language';
import {
editorDarkHightlightStyle,
editorDarkTheme,
} from './sql-code-editor-theme';
const External = Annotation.define<boolean>();
export interface UseSqlEditorProps {
value?: string;
autoFocus?: boolean;
onChange?: (value: string, viewUpdate: ViewUpdate) => void;
onUpdate?: (viewUpdate: ViewUpdate) => void;
onCreateEditor?: (view: EditorView) => void;
extensions?: Extension[];
container?: RefObject<HTMLDivElement>;
}
export function useSqlEditor(props: UseSqlEditorProps) {
const {
value: initialValue,
onChange,
onCreateEditor,
onUpdate,
container,
extensions = [],
} = props;
const [editor, setEditor] = useState<EditorView | null>(null);
const updateListener = EditorView.updateListener.of((vu) => {
if (
vu.docChanged &&
typeof onChange === 'function' &&
// Fix echoing of the remote changes:
// If transaction is market as remote we don't have to call `onChange` handler again
!vu.transactions.some((tr) => tr.annotation(External))
) {
const doc = vu.state.doc;
const value = doc.toString();
onChange(value, vu);
}
onUpdate?.(vu);
});
useEffect(() => {
if (!container?.current) {
return;
}
let editorInstance = new EditorView({
doc: initialValue,
extensions: [
basicSetup,
history(),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
updateListener,
editorDarkTheme,
syntaxHighlighting(editorDarkHightlightStyle),
EditorView.contentAttributes.of({
'data-enable-grammarly': 'false',
}),
].concat(extensions),
parent: container.current,
});
onCreateEditor?.(editorInstance);
setEditor(editorInstance);
return () => {
editorInstance.destroy();
setEditor(null);
};
}, [container]);
return editor;
}

@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import initSqlJs, { type SqlJsStatic } from 'sql.js';
export function useSqlite() {
const [sql, setSql] = useState<SqlJsStatic | null>(null);
useEffect(() => {
(async () => {
const SQL = await initSqlJs({
locateFile: (file) => {
return `https://sql.js.org/dist/${file}`;
},
});
setSql(SQL);
})();
}, []);
return sql;
}

@ -0,0 +1,34 @@
---
title: Challenge 1
description: Write a SQL query to find the total number of orders in the `orders` table.
order: 100
defaultValue: SELECT * FROM orders;
initSteps:
- CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER,
order_date DATE,
total DECIMAL(10, 2)
);
- INSERT INTO orders (id, customer_id, order_date, total)
VALUES
(1, 1, '2021-01-01', 100.00),
(2, 2, '2021-01-02', 200.00),
(3, 1, '2021-01-03', 300.00),
(4, 3, '2021-01-04', 400.00),
(5, 2, '2021-01-05', 500.00);
expectedResults:
- columns: [total_orders]
values:
- [5]
---
<!-- /sql/:chapterId/:(lessonId/challengeId/quizId) -->
## Instructions
Write a SQL query to find the total number of orders in the `orders` table.
## Result
Your query should return a single column with the total number of orders in the `orders` table.

@ -0,0 +1,5 @@
---
title: Introduction
description: Learn the basics of SQL, the language for querying databases.
order: 1
---

@ -0,0 +1,9 @@
---
title: Intro to SQL
description: Learn the basics of SQL, the language for querying databases.
order: 1
---
The SQL language is widely used today across web frameworks and database applications. Knowing SQL gives you the freedom to explore your data, and the power to make better decisions. By learning SQL, you will also learn concepts that apply to nearly every data storage system.
The statements covered in this course use SQLite Relational Database Management System (RDBMS). You can also access a glossary of all the SQL commands taught in this course.

@ -0,0 +1,5 @@
---
title: Learn SQL
description: Learn the basics of SQL, the language for querying databases.
order: 1
---

@ -0,0 +1,242 @@
import type { MarkdownFileType } from './file';
export interface CourseFrontmatter {
order: number;
title: string;
description: string;
}
export type LessonFrontmatter = {
title: string;
description: string;
order: number;
};
export type LessonFileType = MarkdownFileType<LessonFrontmatter> & {
id: string;
};
export type QuizFrontmatter = {
id: string;
order: number;
title: string;
};
export type QuizFileType = MarkdownFileType<QuizFrontmatter> & {
id: string;
};
export type ChallengeFrontmatter = {
title: string;
desctiption: string;
order: number;
defaultValue?: string;
initSteps?: string[];
expectedResults?: {
columns: string[];
values: string[][];
}[];
};
export type ChallengeFileType = MarkdownFileType<ChallengeFrontmatter> & {
id: string;
};
export type ChapterFrontmatter = {
id: string;
order: number;
title: string;
};
export type ChapterFileType = MarkdownFileType<ChapterFrontmatter> & {
id: string;
lessons: LessonFileType[];
exercises: (QuizFileType | ChallengeFileType)[];
};
export type CourseFileType = MarkdownFileType<CourseFrontmatter> & {
id: string;
chapters: ChapterFileType[];
};
function coursePathToId(filePath: string): string {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.md', '');
}
/**
* Gets the IDs of all the courses available on the website
*
* @returns string[] Array of course IDs
*/
export async function getCourseIds() {
const courseFiles = import.meta.glob<CourseFileType>(
'/src/data/courses/*/*.md',
{
eager: true,
},
);
return Object.keys(courseFiles).map(coursePathToId);
}
export async function getCourseById(id: string): Promise<CourseFileType> {
const courseFilesMap: Record<string, CourseFileType> =
import.meta.glob<CourseFileType>('/src/data/courses/*/*.md', {
eager: true,
});
const courseFile = Object.values(courseFilesMap).find((courseFile) => {
return coursePathToId(courseFile.file) === id;
});
if (!courseFile) {
throw new Error(`Course with ID ${id} not found`);
}
const chapters = await getChaptersByCourseId(id);
return {
...courseFile,
id: coursePathToId(courseFile.file),
chapters,
};
}
export function chapterPathToId(filePath: string): string {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.md', '');
}
export async function getChaptersByCourseId(courseId: string) {
const chapterFilesMap = import.meta.glob<ChapterFileType>(
`/src/data/courses/*/chapters/*/*.md`,
{
eager: true,
},
);
const chapterFiles = Object.values(chapterFilesMap).filter((chapterFile) => {
const [_, currentCourseId] =
chapterFile.file.match(/\/courses\/([^/]+)\/chapters/) || [];
return currentCourseId === courseId;
});
const enrichedChapters: ChapterFileType[] = [];
for (const chapterFile of chapterFiles) {
const chapterId = chapterPathToId(chapterFile.file);
const lessons = await getLessonsByCourseId(courseId, chapterId);
const exercises = await getExercisesByCourseId(courseId, chapterId);
enrichedChapters.push({
...chapterFile,
id: chapterId,
lessons,
exercises,
});
}
return enrichedChapters.sort(
(a, b) => a.frontmatter.order - b.frontmatter.order,
);
}
export function lessonPathToId(filePath: string): string {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.md', '');
}
export async function getLessonsByCourseId(
courseId: string,
chapterId: string,
): Promise<LessonFileType[]> {
const lessonFilesMap = import.meta.glob<LessonFileType>(
`/src/data/courses/*/chapters/*/lessons/*.md`,
{
eager: true,
},
);
return Object.values(lessonFilesMap)
.filter((lessonFile) => {
const [, currentCourseId, currentChapterId] =
lessonFile.file.match(
/\/courses\/([^/]+)\/chapters\/([^/]+)\/lessons/,
) || [];
return currentCourseId === courseId && currentChapterId === chapterId;
})
.map((lessonFile) => ({
...lessonFile,
id: lessonPathToId(lessonFile.file),
}))
.sort((a, b) => a.frontmatter.order - b.frontmatter.order);
}
export function exercisePathToId(filePath: string): string {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.md', '');
}
export async function getExercisesByCourseId(
courseId: string,
chapterId: string,
): Promise<(QuizFileType | ChallengeFileType)[]> {
const exerciseFilesMap = import.meta.glob<QuizFileType | ChallengeFileType>(
`/src/data/courses/*/chapters/*/exercises/*.md`,
{
eager: true,
},
);
return Object.values(exerciseFilesMap)
.filter((exerciseFile) => {
const [, currentCourseId, currentChapterId] =
exerciseFile.file.match(
/\/courses\/([^/]+)\/chapters\/([^/]+)\/exercises/,
) || [];
return currentCourseId === courseId && currentChapterId === chapterId;
})
.map((exerciseFile) => ({
...exerciseFile,
id: exercisePathToId(exerciseFile.file),
}))
.sort((a, b) => a.frontmatter.order - b.frontmatter.order);
}
export async function getCourseExerciseById(
courseId: string,
chapterId: string,
exerciseId: string,
) {
const exerciseFilesMap = import.meta.glob<QuizFileType | ChallengeFileType>(
`/src/data/courses/*/chapters/*/exercises/*.md`,
{
eager: true,
},
);
const exerciseFile = Object.values(exerciseFilesMap).find((exerciseFile) => {
const [, currentCourseId, currentChapterId, currentExerciseId] =
exerciseFile.file.match(
/\/courses\/([^/]+)\/chapters\/([^/]+)\/exercises\/([^/]+)\.md/,
) || [];
return (
currentCourseId === courseId &&
currentChapterId === chapterId &&
currentExerciseId === exerciseId
);
});
if (!exerciseFile) {
throw new Error(`Exercise with ID ${exerciseId} not found`);
}
return {
...exerciseFile,
id: exercisePathToId(exerciseFile.file),
};
}

@ -0,0 +1,23 @@
---
import { ChallengeView } from '../../components/Course/ChallengeView';
import type { ChallengeFileType } from '../../lib/course';
import { getCourseById, getCourseExerciseById } from '../../lib/course';
const course = await getCourseById('sql');
const exercise = (await getCourseExerciseById(
'sql',
'introduction',
'challenge-1',
)) as ChallengeFileType;
---
<ChallengeView
defaultValue={exercise.frontmatter.defaultValue}
expectedResults={exercise.frontmatter.expectedResults}
initSteps={exercise.frontmatter.initSteps}
client:load
>
<div class='prose prose-xl prose-invert'>
<exercise.Content />
</div>
</ChallengeView>
Loading…
Cancel
Save