@curio-sd/e-module-builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ @import "tailwindcss";
2
+ @import "highlight.js/styles/github-dark.css";
3
+
4
+ /* Scan generated pages and copied JS — paths are relative to src/css/ in PROJECT_DIR */
5
+ @source "../../index.html";
6
+ @source "../../pages/**/*.html";
7
+ @source "../js/**/*.js";
8
+
9
+ @theme {
10
+ --font-sans: "Plus Jakarta Sans", system-ui, sans-serif;
11
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
12
+
13
+ --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04);
14
+ --shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.06);
15
+ --shadow-editor: 0 1px 2px rgba(0, 0, 0, 0.08);
16
+ }
17
+
18
+ @layer base {
19
+ html {
20
+ scroll-behavior: smooth;
21
+ }
22
+
23
+ body {
24
+ @apply text-zinc-800;
25
+ }
26
+ }
27
+
28
+ @layer components {
29
+ .app-shell {
30
+ @apply min-h-screen bg-[#f4f4f5];
31
+ }
32
+
33
+ .sidebar-panel {
34
+ @apply fixed inset-y-0 left-0 z-50 flex w-60 -translate-x-full flex-col overflow-hidden bg-zinc-950 transition-transform duration-300 ease-out md:translate-x-0;
35
+ }
36
+
37
+ .sidebar-panel [data-module-nav] {
38
+ @apply flex min-h-0 flex-1 flex-col;
39
+ }
40
+
41
+ .sidebar-logo {
42
+ @apply h-auto w-38.75 max-w-full;
43
+ }
44
+
45
+ .sidebar-scroll {
46
+ @apply min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-4;
47
+ scrollbar-width: thin;
48
+ scrollbar-color: theme('colors.zinc.700') transparent;
49
+ }
50
+
51
+ .sidebar-scroll::-webkit-scrollbar {
52
+ width: 6px;
53
+ }
54
+
55
+ .sidebar-scroll::-webkit-scrollbar-track {
56
+ background: transparent;
57
+ }
58
+
59
+ .sidebar-scroll::-webkit-scrollbar-thumb {
60
+ @apply rounded-full bg-zinc-700;
61
+ }
62
+
63
+ .sidebar-scroll::-webkit-scrollbar-thumb:hover {
64
+ @apply bg-zinc-600;
65
+ }
66
+
67
+ .topbar {
68
+ @apply sticky top-0 z-30 border-b border-zinc-200/80 bg-[#f4f4f5]/90 backdrop-blur-md;
69
+ }
70
+
71
+ .btn-primary {
72
+ @apply inline-flex items-center justify-center bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2;
73
+ }
74
+
75
+ .btn-secondary {
76
+ @apply inline-flex items-center justify-center border border-zinc-300 bg-white px-5 py-2.5 text-sm font-medium text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2;
77
+ }
78
+
79
+ .card {
80
+ @apply bg-white p-6 shadow-card;
81
+ }
82
+
83
+ .card-interactive {
84
+ @apply bg-white p-6 shadow-card transition duration-200 hover:shadow-card-hover;
85
+ }
86
+
87
+ .card-muted {
88
+ @apply bg-zinc-50 p-6;
89
+ }
90
+
91
+ .week-label {
92
+ @apply mb-3 block text-[11px] font-semibold uppercase tracking-[0.2em] text-zinc-400;
93
+ }
94
+
95
+ .text-link {
96
+ @apply text-sm font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition hover:decoration-zinc-900;
97
+ }
98
+
99
+ .progress-track {
100
+ @apply h-px overflow-hidden bg-zinc-200;
101
+ }
102
+
103
+ .progress-fill {
104
+ @apply h-full bg-zinc-900 transition-all duration-500;
105
+ }
106
+
107
+ .code-block {
108
+ @apply overflow-x-auto bg-zinc-950 p-5 font-mono text-[13px] leading-relaxed text-zinc-300;
109
+ }
110
+
111
+ .callout {
112
+ @apply mt-4 bg-zinc-50 p-4 text-sm leading-relaxed text-zinc-600;
113
+ }
114
+
115
+ .compare-cell {
116
+ @apply bg-zinc-50 p-5;
117
+ }
118
+
119
+ .editor-panel {
120
+ @apply overflow-hidden bg-zinc-950 shadow-editor;
121
+ }
122
+
123
+ .preview-panel {
124
+ @apply overflow-hidden bg-white shadow-card;
125
+ }
126
+
127
+ .panel-header {
128
+ @apply border-b border-zinc-800 px-4 py-2.5 text-[11px] font-medium uppercase tracking-wider text-zinc-500;
129
+ }
130
+
131
+ .panel-header-light {
132
+ @apply border-b border-zinc-100 px-4 py-2.5 text-[11px] font-medium uppercase tracking-wider text-zinc-400;
133
+ }
134
+
135
+ .feedback-box {
136
+ @apply border border-zinc-200 bg-white p-4 text-sm;
137
+ }
138
+
139
+ .badge {
140
+ @apply inline-block text-[11px] font-medium uppercase tracking-wider text-zinc-500;
141
+ }
142
+
143
+ .badge-done {
144
+ @apply text-[11px] font-medium uppercase tracking-wider text-zinc-600;
145
+ }
146
+
147
+ .monaco-container {
148
+ @apply h-80 min-h-80 w-full overflow-hidden;
149
+ }
150
+
151
+ .nav-overlay {
152
+ @apply fixed inset-0 z-40 hidden bg-black/40 backdrop-blur-sm md:hidden;
153
+ }
154
+
155
+ .nav-cta {
156
+ @apply mx-3 mb-2 mt-1 flex items-center gap-2 border border-zinc-600 bg-zinc-900 px-3 py-2.5 text-[13px] font-medium text-zinc-100 transition hover:border-zinc-500 hover:bg-zinc-800 hover:text-white;
157
+ }
158
+
159
+ /* ── Theory prose typography ─────────────────────────────────────────────── */
160
+
161
+ .prose-theory {
162
+ @apply space-y-6 text-zinc-700;
163
+ }
164
+
165
+ .prose-theory h2 {
166
+ @apply mt-8 text-xl font-semibold text-zinc-900 first:mt-0;
167
+ }
168
+
169
+ .prose-theory h3 {
170
+ @apply mt-4 text-base font-semibold text-zinc-900;
171
+ }
172
+
173
+ .prose-theory p {
174
+ @apply leading-relaxed;
175
+ }
176
+
177
+ .prose-theory ul {
178
+ @apply list-inside list-disc space-y-1.5 pl-1;
179
+ }
180
+
181
+ .prose-theory ol {
182
+ @apply list-inside list-decimal space-y-1.5 pl-1;
183
+ }
184
+
185
+ .prose-theory li {
186
+ @apply leading-relaxed;
187
+ }
188
+
189
+ .prose-theory a {
190
+ @apply font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition hover:decoration-zinc-900;
191
+ }
192
+
193
+ .prose-theory code {
194
+ @apply rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-sm text-zinc-800;
195
+ }
196
+
197
+ .prose-theory pre {
198
+ @apply overflow-x-auto bg-zinc-950 p-5 font-mono text-[13px] leading-relaxed text-zinc-300;
199
+ }
200
+
201
+ .prose-theory pre code {
202
+ @apply bg-transparent p-0 text-inherit;
203
+ }
204
+
205
+ .prose-theory blockquote {
206
+ @apply border-l-4 border-zinc-300 pl-4 italic text-zinc-500;
207
+ }
208
+
209
+ .prose-theory table {
210
+ @apply w-full text-left text-sm;
211
+ }
212
+
213
+ .prose-theory thead tr {
214
+ @apply border-b border-zinc-200 text-zinc-500;
215
+ }
216
+
217
+ .prose-theory tbody tr {
218
+ @apply border-b border-zinc-100 last:border-0;
219
+ }
220
+
221
+ .prose-theory th,
222
+ .prose-theory td {
223
+ @apply py-2 pr-4;
224
+ }
225
+
226
+ /* ── Custom components ───────────────────────────────────────────────────── */
227
+
228
+ x-card {
229
+ @apply block bg-white p-6 shadow-card;
230
+ }
231
+
232
+ x-card>h2 {
233
+ @apply text-lg font-medium text-zinc-900;
234
+ }
235
+
236
+ x-callout {
237
+ @apply block bg-zinc-50 p-4 text-sm leading-relaxed text-zinc-600;
238
+ }
239
+
240
+ x-callout[type="tip"] {
241
+ @apply border-l-4 border-l-emerald-500 bg-emerald-50 text-emerald-800;
242
+ }
243
+
244
+ x-callout[type="warning"] {
245
+ @apply border-l-4 border-l-amber-500 bg-amber-50 text-amber-800;
246
+ }
247
+
248
+ x-compare {
249
+ @apply grid gap-4 sm:grid-cols-2;
250
+ }
251
+
252
+ x-compare-item {
253
+ @apply block bg-zinc-50 p-5;
254
+ }
255
+
256
+ x-compare-item>h3 {
257
+ @apply font-medium text-zinc-900;
258
+ }
259
+
260
+ x-nav {
261
+ @apply mt-10 flex flex-col gap-4 bg-white p-6 shadow-card sm:flex-row sm:items-center sm:justify-end;
262
+ }
263
+
264
+ x-nav[label] {
265
+ @apply sm:justify-between;
266
+ }
267
+
268
+ x-nav>.x-nav-label {
269
+ @apply font-medium text-zinc-900;
270
+ }
271
+
272
+ x-nav>p {
273
+ @apply flex shrink-0 flex-wrap gap-3;
274
+ }
275
+
276
+ x-nav a {
277
+ @apply inline-flex shrink-0 items-center justify-center border border-zinc-300 bg-white px-5 py-2.5 text-sm font-medium text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 !no-underline;
278
+ }
279
+
280
+ x-nav>p>a:last-child {
281
+ @apply border-transparent bg-zinc-900 text-white hover:border-transparent hover:bg-zinc-800 focus-visible:ring-zinc-900;
282
+ }
283
+ }
@@ -0,0 +1,99 @@
1
+ import checklistData from '../data/checklist.json'
2
+ import { getChecklistState, setChecklistItem } from './storage.js'
3
+
4
+ export function initChecklist() {
5
+ const container = document.querySelector('[data-checklist]')
6
+ if (!container) return
7
+
8
+ const state = getChecklistState()
9
+
10
+ container.innerHTML = checklistData.groups
11
+ .map((group) => {
12
+ const items = group.items
13
+ .map((item) => {
14
+ const checked = !!state[item.id]
15
+ return `
16
+ <label class="flex cursor-pointer items-start gap-3 border-b border-zinc-100 py-3 transition last:border-0 hover:bg-zinc-50/50">
17
+ <input
18
+ type="checkbox"
19
+ data-check-id="${item.id}"
20
+ class="mt-0.5 h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900"
21
+ ${checked ? 'checked' : ''}
22
+ />
23
+ <span class="text-sm leading-relaxed ${checked ? 'text-zinc-400 line-through' : 'text-zinc-700'}">${item.text}</span>
24
+ </label>
25
+ `
26
+ })
27
+ .join('')
28
+
29
+ return `
30
+ <section class="card">
31
+ <h2 class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">${group.title}</h2>
32
+ <div class="mt-4">${items}</div>
33
+ </section>
34
+ `
35
+ })
36
+ .join('')
37
+
38
+ container.querySelectorAll('input[type="checkbox"]').forEach((input) => {
39
+ input.addEventListener('change', () => {
40
+ setChecklistItem(input.dataset.checkId, input.checked)
41
+ const label = input.closest('label').querySelector('span')
42
+ if (input.checked) {
43
+ label.classList.add('text-zinc-400', 'line-through')
44
+ label.classList.remove('text-zinc-700')
45
+ } else {
46
+ label.classList.remove('text-zinc-400', 'line-through')
47
+ label.classList.add('text-zinc-700')
48
+ }
49
+ updateProgress()
50
+ })
51
+ })
52
+
53
+ updateProgress()
54
+ }
55
+
56
+ function updateProgress() {
57
+ const state = getChecklistState()
58
+ let total = 0
59
+ let checked = 0
60
+ for (const group of checklistData.groups) {
61
+ for (const item of group.items) {
62
+ total++
63
+ if (state[item.id]) checked++
64
+ }
65
+ }
66
+ const percent = total ? Math.round((checked / total) * 100) : 0
67
+ const bar = document.querySelector('[data-checklist-progress]')
68
+ const label = document.querySelector('[data-checklist-progress-label]')
69
+ if (bar) bar.style.width = `${percent}%`
70
+ if (label) label.textContent = `${checked} / ${total}`
71
+ }
72
+
73
+ export function initChecklistExport() {
74
+ const btn = document.querySelector('[data-export-checklist]')
75
+ if (!btn) return
76
+
77
+ btn.addEventListener('click', async () => {
78
+ const state = getChecklistState()
79
+ const lines = ['CSS Grid — Skills Checklist', '========================', '']
80
+
81
+ for (const group of checklistData.groups) {
82
+ lines.push(group.title)
83
+ for (const item of group.items) {
84
+ const mark = state[item.id] ? '[x]' : '[ ]'
85
+ lines.push(` ${mark} ${item.text.replace(/`/g, '')}`)
86
+ }
87
+ lines.push('')
88
+ }
89
+
90
+ const text = lines.join('\n')
91
+ try {
92
+ await navigator.clipboard.writeText(text)
93
+ btn.textContent = 'Gekopieerd'
94
+ setTimeout(() => { btn.textContent = 'Exporteer naar klembord' }, 2000)
95
+ } catch {
96
+ btn.textContent = 'Kopiëren mislukt'
97
+ }
98
+ })
99
+ }
@@ -0,0 +1,184 @@
1
+ import { createCssEditor, setEditorValue, getEditorValue } from '../monaco-setup.js'
2
+ import { runChecks, validateAreas } from './validators.js'
3
+ import { renderExerciseMeta, markExerciseSolved, getSolvedExercises } from './exercise-shared.js'
4
+
5
+ export { renderExerciseMeta, markExerciseSolved, getSolvedExercises }
6
+
7
+ function buildPreviewDoc(previewHtml, css) {
8
+ if (previewHtml.includes('</style>')) {
9
+ return previewHtml.replace('</style>', `\n${css}\n</style>`)
10
+ }
11
+ return `<!DOCTYPE html><html><head><style>${css}</style></head><body>${previewHtml}</body></html>`
12
+ }
13
+
14
+ function showFeedback(el, html, type = 'info') {
15
+ if (!el) return
16
+ el.classList.remove('hidden')
17
+ el.innerHTML = html
18
+ }
19
+
20
+ export function initCssPlayground(exercise, { onSolved } = {}) {
21
+ const container = document.querySelector('[data-editor]')
22
+ const iframe = document.querySelector('[data-preview]')
23
+ const feedback = document.querySelector('[data-feedback]')
24
+ if (!container || !iframe) return
25
+
26
+ const editor = createCssEditor(container, exercise.starterCss, (css) => {
27
+ iframe.srcdoc = buildPreviewDoc(exercise.previewHtml, css)
28
+ })
29
+
30
+ iframe.srcdoc = buildPreviewDoc(exercise.previewHtml, exercise.starterCss)
31
+
32
+ document.querySelector('[data-hint]')?.addEventListener('click', () => {
33
+ showFeedback(feedback, `<p class="text-zinc-600">${exercise.hint.replace(/\n/g, '<br>')}</p>`)
34
+ })
35
+
36
+ document.querySelector('[data-solution]')?.addEventListener('click', () => {
37
+ setEditorValue(editor, exercise.solution)
38
+ iframe.srcdoc = buildPreviewDoc(exercise.previewHtml, exercise.solution)
39
+ showFeedback(feedback, '<p class="text-zinc-600">Oplossing geladen. Bestudeer de code en probeer het daarna zelf.</p>')
40
+ })
41
+
42
+ document.querySelector('[data-check]')?.addEventListener('click', () => {
43
+ const css = getEditorValue(editor)
44
+ const results = runChecks(css, exercise.checks)
45
+ const failed = results.filter((r) => !r.ok)
46
+
47
+ if (failed.length === 0) {
48
+ showFeedback(feedback, '<p class="font-medium text-zinc-900">Goed gedaan — oefening voltooid.</p>')
49
+ onSolved?.(exercise.id)
50
+ } else {
51
+ showFeedback(
52
+ feedback,
53
+ `<p class="font-medium text-zinc-900">Nog niet compleet:</p><ul class="mt-2 list-inside list-disc text-sm text-zinc-500">${failed.map((f) => `<li>${f.msg}</li>`).join('')}</ul>`
54
+ )
55
+ }
56
+ })
57
+
58
+ return editor
59
+ }
60
+
61
+ export function initAreasExercise(exercise, { onSolved } = {}) {
62
+ const containerInput = document.querySelector('[data-areas-container]')
63
+ const preview = document.querySelector('[data-areas-preview]')
64
+ const feedback = document.querySelector('[data-feedback]')
65
+ const selects = document.querySelectorAll('[data-area-item]')
66
+
67
+ if (!containerInput || !preview) return
68
+
69
+ const updatePreview = () => {
70
+ const areas = containerInput.value.trim()
71
+ const itemStyles = Array.from(selects)
72
+ .map((s) => `.${s.dataset.areaItem} { grid-area: ${s.value}; }`)
73
+ .join('\n')
74
+
75
+ preview.innerHTML = `
76
+ <style>
77
+ .news-grid {
78
+ display: grid;
79
+ grid-template-columns: ${exercise.gridColumns || '1fr 1fr 1fr'};
80
+ grid-template-areas: ${areas || '"a b c"'};
81
+ gap: 6px;
82
+ min-height: 180px;
83
+ }
84
+ ${exercise.previewStyles || ''}
85
+ ${itemStyles}
86
+ </style>
87
+ ${exercise.previewHtml}
88
+ `
89
+ }
90
+
91
+ containerInput.addEventListener('input', updatePreview)
92
+ selects.forEach((s) => s.addEventListener('change', updatePreview))
93
+ updatePreview()
94
+
95
+ document.querySelector('[data-hint]')?.addEventListener('click', () => {
96
+ showFeedback(feedback, `<p class="text-zinc-600">${exercise.hint}</p>`)
97
+ })
98
+
99
+ document.querySelector('[data-check]')?.addEventListener('click', () => {
100
+ const errors = validateAreas(containerInput.value, selects, exercise.expected)
101
+ if (errors.length === 0) {
102
+ showFeedback(feedback, '<p class="font-medium text-zinc-900">Perfect — oefening voltooid.</p>')
103
+ onSolved?.(exercise.id)
104
+ } else {
105
+ showFeedback(
106
+ feedback,
107
+ `<p class="font-medium text-zinc-900">Nog niet goed:</p><ul class="mt-2 list-inside list-disc text-sm text-zinc-500">${errors.map((e) => `<li>${e}</li>`).join('')}</ul>`
108
+ )
109
+ }
110
+ })
111
+ }
112
+
113
+ export function initResponsiveExercise(exercise, { onSolved } = {}) {
114
+ const container = document.querySelector('[data-editor]')
115
+ const iframe = document.querySelector('[data-preview]')
116
+ const feedback = document.querySelector('[data-feedback]')
117
+ const viewportLabel = document.querySelector('[data-viewport-label]')
118
+ if (!container || !iframe) return
119
+
120
+ let currentViewport = 'desktop'
121
+ const viewports = exercise.viewports || { desktop: 960, tablet: 700, mobile: 360 }
122
+
123
+ const editor = createCssEditor(container, exercise.starterCss, (css) => {
124
+ iframe.srcdoc = buildPreviewDoc(exercise.previewHtml, css)
125
+ })
126
+
127
+ const applyViewport = () => {
128
+ iframe.style.width = `${viewports[currentViewport]}px`
129
+ iframe.style.maxWidth = '100%'
130
+ if (viewportLabel) viewportLabel.textContent = `${currentViewport} (${viewports[currentViewport]}px)`
131
+ iframe.srcdoc = buildPreviewDoc(exercise.previewHtml, getEditorValue(editor))
132
+ }
133
+
134
+ document.querySelectorAll('[data-viewport]').forEach((btn) => {
135
+ btn.addEventListener('click', () => {
136
+ currentViewport = btn.dataset.viewport
137
+ document.querySelectorAll('[data-viewport]').forEach((b) => {
138
+ b.classList.toggle('btn-primary', b.dataset.viewport === currentViewport)
139
+ b.classList.toggle('btn-secondary', b.dataset.viewport !== currentViewport)
140
+ })
141
+ applyViewport()
142
+ })
143
+ })
144
+
145
+ document.querySelector('[data-solution]')?.addEventListener('click', () => {
146
+ setEditorValue(editor, exercise.solution)
147
+ applyViewport()
148
+ showFeedback(feedback, '<p class="text-zinc-600">Oplossing geladen.</p>')
149
+ })
150
+
151
+ document.querySelector('[data-check]')?.addEventListener('click', () => {
152
+ const css = getEditorValue(editor)
153
+ const results = runChecks(css, exercise.checks)
154
+ const failed = results.filter((r) => !r.ok)
155
+
156
+ if (failed.length === 0) {
157
+ showFeedback(feedback, '<p class="font-medium text-zinc-900">Uitstekend — oefening voltooid.</p>')
158
+ onSolved?.(exercise.id)
159
+ } else {
160
+ showFeedback(
161
+ feedback,
162
+ `<p class="font-medium text-zinc-900">Nog niet compleet:</p><ul class="mt-2 list-inside list-disc text-sm text-zinc-500">${failed.map((f) => `<li>${f.msg}</li>`).join('')}</ul>`
163
+ )
164
+ }
165
+ })
166
+
167
+ applyViewport()
168
+ }
169
+
170
+ export function renderAreaSelects(items, options) {
171
+ return items
172
+ .map(
173
+ (id) => `
174
+ <div class="flex items-center justify-between border-b border-zinc-100 py-3 last:border-0">
175
+ <span class="font-medium text-zinc-700">Item ${id.toUpperCase()}</span>
176
+ <select data-area-item="${id}" class="border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-700 focus:border-zinc-900 focus:outline-none">
177
+ ${options.map((o) => `<option value="${o}" ${o === id ? 'selected' : ''}>${o}</option>`).join('')}
178
+ </select>
179
+ </div>
180
+ `
181
+ )
182
+ .join('')
183
+ }
184
+
@@ -0,0 +1,37 @@
1
+ const DIFFICULTY = ['', 'Beginner', 'Beginner', 'Gemiddeld', 'Gemiddeld', 'Gevorderd', 'Gevorderd', 'Expert', 'Expert']
2
+
3
+ export function renderExerciseMeta(exercise) {
4
+ const diffEl = document.querySelector('[data-difficulty]')
5
+ const titleEl = document.querySelector('[data-exercise-title]')
6
+ const descEl = document.querySelector('[data-exercise-desc]')
7
+
8
+ if (diffEl) {
9
+ const level = exercise.difficulty || 1
10
+ const label = DIFFICULTY[level] || 'Beginner'
11
+ diffEl.className = 'badge'
12
+ diffEl.textContent = `Oefening ${String(exercise.id).padStart(2, '0')} · ${label}`
13
+ }
14
+ if (titleEl) titleEl.textContent = exercise.title
15
+ if (descEl) descEl.textContent = exercise.description
16
+ }
17
+
18
+ export function markExerciseSolved(week, id) {
19
+ const key = `grid-module:exercises:week${week}`
20
+ try {
21
+ const raw = localStorage.getItem(key)
22
+ const solved = raw ? JSON.parse(raw) : []
23
+ if (!solved.includes(id)) {
24
+ solved.push(id)
25
+ localStorage.setItem(key, JSON.stringify(solved))
26
+ }
27
+ } catch { /* ignore */ }
28
+ }
29
+
30
+ export function getSolvedExercises(week) {
31
+ try {
32
+ const raw = localStorage.getItem(`grid-module:exercises:week${week}`)
33
+ return raw ? JSON.parse(raw) : []
34
+ } catch {
35
+ return []
36
+ }
37
+ }
@@ -0,0 +1,71 @@
1
+ import { renderExerciseMeta, markExerciseSolved } from './exercise-shared.js'
2
+
3
+ function showFeedback(el, html) {
4
+ if (!el) return
5
+ el.classList.remove('hidden')
6
+ el.innerHTML = html
7
+ }
8
+
9
+ function renderSteps(steps) {
10
+ if (!steps?.length) return ''
11
+ return `<ol class="mt-4 list-decimal space-y-2 pl-5 text-sm text-zinc-600">${steps
12
+ .map((step) => `<li>${step}</li>`)
13
+ .join('')}</ol>`
14
+ }
15
+
16
+ function renderList(items, label) {
17
+ if (!items?.length) return ''
18
+ return `
19
+ <div class="mt-6">
20
+ <p class="text-sm font-medium text-zinc-900">${label}</p>
21
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-sm text-zinc-600">${items
22
+ .map((item) => `<li>${item}</li>`)
23
+ .join('')}</ul>
24
+ </div>`
25
+ }
26
+
27
+ export function initExternalExercise(exercise, weekNum, { onSolved } = {}) {
28
+ renderExerciseMeta(exercise)
29
+
30
+ document.querySelector('[data-exercise-interactive]')?.classList.add('hidden')
31
+ document.querySelector('[data-exercise-external]')?.classList.remove('hidden')
32
+ document.querySelector('[data-solution-label]')?.replaceChildren(document.createTextNode('Toon voorbeeld'))
33
+ document.querySelector('[data-check-label]')?.replaceChildren(document.createTextNode('Markeer als voltooid'))
34
+
35
+ const taskEl = document.querySelector('[data-external-task]')
36
+ const stepsEl = document.querySelector('[data-external-steps]')
37
+ const envEl = document.querySelector('[data-external-environment]')
38
+ const deliverablesEl = document.querySelector('[data-external-deliverables]')
39
+ const feedback = document.querySelector('[data-feedback]')
40
+
41
+ if (taskEl) taskEl.innerHTML = exercise.task || exercise.description || ''
42
+ if (stepsEl) stepsEl.innerHTML = renderSteps(exercise.steps)
43
+ if (envEl) envEl.textContent = exercise.environment || 'Je eigen omgeving (editor, server of tool)'
44
+ if (deliverablesEl) {
45
+ deliverablesEl.innerHTML = renderList(exercise.deliverables, 'Inleveren / tonen')
46
+ }
47
+
48
+ document.querySelector('[data-hint]')?.addEventListener('click', () => {
49
+ showFeedback(
50
+ feedback,
51
+ `<p class="text-zinc-600">${(exercise.hint || 'Werk de stappen rustig door in je eigen omgeving.').replace(/\n/g, '<br>')}</p>`
52
+ )
53
+ })
54
+
55
+ document.querySelector('[data-solution]')?.addEventListener('click', () => {
56
+ const solution = exercise.solution || 'Vergelijk je werk met de theorie en vraag feedback aan je docent.'
57
+ showFeedback(
58
+ feedback,
59
+ `<p class="font-medium text-zinc-900">Voorbeelduitwerking</p><p class="mt-2 text-sm text-zinc-600">${solution.replace(/\n/g, '<br>')}</p>`
60
+ )
61
+ })
62
+
63
+ document.querySelector('[data-check]')?.addEventListener('click', () => {
64
+ showFeedback(
65
+ feedback,
66
+ '<p class="font-medium text-zinc-900">Opdracht afgerond in je eigen omgeving?</p><p class="mt-2 text-sm text-zinc-500">Markeer als voltooid als je klaar bent. De docent beoordeelt buiten deze site.</p>'
67
+ )
68
+ onSolved?.(exercise.id)
69
+ markExerciseSolved(weekNum, exercise.id)
70
+ })
71
+ }