@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.
- package/LICENSE +7 -0
- package/bin/cli.js +83 -0
- package/build.mjs +339 -0
- package/package.json +29 -0
- package/public/Logo Schaalbaar (1).svg +293 -0
- package/public/favicon.svg +6 -0
- package/public/logo.svg +293 -0
- package/src/css/main.css +283 -0
- package/src/js/checklist.js +99 -0
- package/src/js/exercises/exercise-runner.js +184 -0
- package/src/js/exercises/exercise-shared.js +37 -0
- package/src/js/exercises/external-exercise.js +71 -0
- package/src/js/exercises/hub.js +57 -0
- package/src/js/exercises/load-exercise.js +309 -0
- package/src/js/exercises/validators.js +53 -0
- package/src/js/home.js +86 -0
- package/src/js/layout.js +42 -0
- package/src/js/monaco-setup.js +49 -0
- package/src/js/nav.js +165 -0
- package/src/js/quiz.js +111 -0
- package/src/js/site-path.js +18 -0
- package/src/js/storage.js +51 -0
- package/src/js/theory.js +41 -0
- package/src/js/thuiswerk.js +161 -0
- package/src/partials/head.html +6 -0
- package/templates/index.html +181 -0
- package/templates/page.html +29 -0
- package/templates/pages/checklist.html +37 -0
- package/templates/pages/inleveropdracht.html +28 -0
- package/templates/pages/oefening.html +158 -0
- package/templates/pages/oefeningen.html +44 -0
- package/templates/pages/theorie.html +22 -0
- package/templates/pages/toets-praktijk.html +25 -0
- package/templates/pages/toets-theorie.html +25 -0
- package/templates/pages/toets.html +33 -0
- package/vite-plugin-html-includes.js +23 -0
- package/vite.config.js +42 -0
package/src/js/nav.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { mountLayout } from './layout.js'
|
|
2
|
+
import manifest from '../data/manifest.json'
|
|
3
|
+
import { sitePath } from './site-path.js'
|
|
4
|
+
|
|
5
|
+
function buildNavItems() {
|
|
6
|
+
const items = [{ href: manifest.nav.home.href, label: manifest.nav.home.label }]
|
|
7
|
+
|
|
8
|
+
for (const week of manifest.nav.weeks) {
|
|
9
|
+
items.push({
|
|
10
|
+
label: week.label,
|
|
11
|
+
title: week.title,
|
|
12
|
+
children: week.children,
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const page of manifest.nav.examPages) {
|
|
17
|
+
items.push(page)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return items
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NAV_ITEMS = buildNavItems()
|
|
24
|
+
|
|
25
|
+
function isActive(href) {
|
|
26
|
+
const path = window.location.pathname.replace(/\/$/, '')
|
|
27
|
+
const target = href.replace(/\/$/, '')
|
|
28
|
+
if (target.endsWith('index.html') || target === '/index.html') {
|
|
29
|
+
return path.endsWith('/') || path.endsWith('/index.html') || path === ''
|
|
30
|
+
}
|
|
31
|
+
return path.endsWith(target) || path.includes(target)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderNavLink(item, nested = false) {
|
|
35
|
+
const active = isActive(item.href)
|
|
36
|
+
const base = nested
|
|
37
|
+
? 'block px-3 py-2 text-[13px] transition'
|
|
38
|
+
: 'block px-3 py-2.5 text-[13px] font-medium transition'
|
|
39
|
+
const classes = active
|
|
40
|
+
? `${base} text-white`
|
|
41
|
+
: `${base} text-zinc-300 hover:text-white`
|
|
42
|
+
|
|
43
|
+
const external = item.external ? ' target="_blank" rel="noopener noreferrer"' : ''
|
|
44
|
+
return `<a href="${sitePath(item.href)}" class="${classes}"${external}>${item.label}</a>`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderCrashCourseLink() {
|
|
48
|
+
const url = manifest.module.youtube
|
|
49
|
+
if (!url) return ''
|
|
50
|
+
|
|
51
|
+
return `
|
|
52
|
+
<a
|
|
53
|
+
href="${url}"
|
|
54
|
+
class="nav-cta"
|
|
55
|
+
target="_blank"
|
|
56
|
+
rel="noopener noreferrer"
|
|
57
|
+
>
|
|
58
|
+
<svg class="h-4 w-4 shrink-0 text-zinc-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
59
|
+
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/>
|
|
60
|
+
</svg>
|
|
61
|
+
Crash Course
|
|
62
|
+
</a>
|
|
63
|
+
`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderNavGroup(group) {
|
|
67
|
+
const childLinks = group.children.map((c) => renderNavLink(c, true)).join('')
|
|
68
|
+
const title = group.title
|
|
69
|
+
? `<p class="mt-0.5 text-[10px] font-normal leading-snug text-zinc-500">${group.title}</p>`
|
|
70
|
+
: ''
|
|
71
|
+
return `
|
|
72
|
+
<div class="space-y-0.5">
|
|
73
|
+
<div class="px-3 pb-1 pt-4">
|
|
74
|
+
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-zinc-400">${group.label}</p>
|
|
75
|
+
${title}
|
|
76
|
+
</div>
|
|
77
|
+
${childLinks}
|
|
78
|
+
</div>
|
|
79
|
+
`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getManifest() {
|
|
83
|
+
return manifest
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function initNav() {
|
|
87
|
+
const navEl = document.querySelector('[data-module-nav]')
|
|
88
|
+
if (!navEl) return
|
|
89
|
+
|
|
90
|
+
const mod = manifest.module
|
|
91
|
+
const parts = []
|
|
92
|
+
for (const item of NAV_ITEMS) {
|
|
93
|
+
if (item.label === 'Week 1' && mod.youtube) {
|
|
94
|
+
parts.push(renderCrashCourseLink())
|
|
95
|
+
}
|
|
96
|
+
parts.push(item.children ? renderNavGroup(item) : renderNavLink(item))
|
|
97
|
+
}
|
|
98
|
+
const links = parts.join('')
|
|
99
|
+
|
|
100
|
+
navEl.innerHTML = `
|
|
101
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
102
|
+
<div class="shrink-0 border-b border-zinc-800 px-5 py-5">
|
|
103
|
+
<a href="${sitePath('/index.html')}" class="flex flex-col items-center text-center">
|
|
104
|
+
<img src="${sitePath('/logo.svg')}" alt="${mod.logoAlt}" class="sidebar-logo" width="155" height="91" />
|
|
105
|
+
<p class="mt-2.5 text-[11px] uppercase tracking-[0.15em] text-zinc-400">${mod.name} — ${mod.subtitle}</p>
|
|
106
|
+
</a>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="sidebar-scroll space-y-1">${links}</div>
|
|
109
|
+
</div>
|
|
110
|
+
`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function initMobileNav() {
|
|
114
|
+
const toggle = document.querySelector('[data-nav-toggle]')
|
|
115
|
+
const sidebar = document.querySelector('[data-sidebar]')
|
|
116
|
+
const overlay = document.querySelector('[data-nav-overlay]')
|
|
117
|
+
|
|
118
|
+
if (!toggle || !sidebar) return
|
|
119
|
+
|
|
120
|
+
const isMobile = () => !window.matchMedia('(min-width: 768px)').matches
|
|
121
|
+
|
|
122
|
+
const close = () => {
|
|
123
|
+
if (!isMobile()) return
|
|
124
|
+
sidebar.classList.add('-translate-x-full')
|
|
125
|
+
overlay?.classList.add('hidden')
|
|
126
|
+
document.body.classList.remove('overflow-hidden')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const open = () => {
|
|
130
|
+
sidebar.classList.remove('-translate-x-full')
|
|
131
|
+
overlay?.classList.remove('hidden')
|
|
132
|
+
document.body.classList.add('overflow-hidden')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
toggle.addEventListener('click', () => {
|
|
136
|
+
if (sidebar.classList.contains('-translate-x-full')) open()
|
|
137
|
+
else close()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
overlay?.addEventListener('click', close)
|
|
141
|
+
sidebar.querySelectorAll('a').forEach((a) => a.addEventListener('click', close))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderBreadcrumbs(crumbs) {
|
|
145
|
+
const el = document.querySelector('[data-breadcrumbs]')
|
|
146
|
+
if (!el || !crumbs?.length) return
|
|
147
|
+
|
|
148
|
+
el.innerHTML = crumbs
|
|
149
|
+
.map((c, i) => {
|
|
150
|
+
const isLast = i === crumbs.length - 1
|
|
151
|
+
const content = c.href && !isLast
|
|
152
|
+
? `<a href="${sitePath(c.href)}" class="text-zinc-500 transition hover:text-zinc-900">${c.label}</a>`
|
|
153
|
+
: `<span class="text-zinc-900">${c.label}</span>`
|
|
154
|
+
const sep = i < crumbs.length - 1 ? '<span class="text-zinc-300">/</span>' : ''
|
|
155
|
+
return `${content}${sep}`
|
|
156
|
+
})
|
|
157
|
+
.join(' ')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function initPage({ breadcrumbs } = {}) {
|
|
161
|
+
mountLayout()
|
|
162
|
+
initNav()
|
|
163
|
+
initMobileNav()
|
|
164
|
+
renderBreadcrumbs(breadcrumbs)
|
|
165
|
+
}
|
package/src/js/quiz.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getQuizScore, setQuizScore, removeQuizScore } from './storage.js'
|
|
2
|
+
|
|
3
|
+
function renderPreview(preview) {
|
|
4
|
+
if (!preview) return ''
|
|
5
|
+
return `
|
|
6
|
+
<div class="my-4 overflow-hidden border border-zinc-100 bg-zinc-50 p-4">
|
|
7
|
+
<style>${preview.css}</style>
|
|
8
|
+
${preview.html}
|
|
9
|
+
</div>
|
|
10
|
+
`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function initQuiz(quizData, containerSelector = '[data-quiz]') {
|
|
14
|
+
const container = document.querySelector(containerSelector)
|
|
15
|
+
if (!container) return
|
|
16
|
+
|
|
17
|
+
const previous = getQuizScore(quizData.title)
|
|
18
|
+
if (previous) {
|
|
19
|
+
showResults(container, quizData, previous, true)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
container.innerHTML = `
|
|
24
|
+
<form data-quiz-form class="space-y-6">
|
|
25
|
+
${quizData.questions
|
|
26
|
+
.map(
|
|
27
|
+
(q, qi) => `
|
|
28
|
+
<fieldset class="card" data-question="${q.id}">
|
|
29
|
+
<legend class="mb-4 text-base font-medium text-zinc-900">
|
|
30
|
+
<span class="mr-3 font-mono text-sm text-zinc-400">${String(qi + 1).padStart(2, '0')}</span>
|
|
31
|
+
${q.question}
|
|
32
|
+
</legend>
|
|
33
|
+
${renderPreview(q.preview)}
|
|
34
|
+
<div class="space-y-2">
|
|
35
|
+
${q.options
|
|
36
|
+
.map(
|
|
37
|
+
(opt, oi) => `
|
|
38
|
+
<label class="flex cursor-pointer items-center gap-3 border border-zinc-200 p-3 transition hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-50">
|
|
39
|
+
<input type="radio" name="${q.id}" value="${oi}" class="h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900" required />
|
|
40
|
+
<span class="text-sm text-zinc-700">${opt}</span>
|
|
41
|
+
</label>
|
|
42
|
+
`
|
|
43
|
+
)
|
|
44
|
+
.join('')}
|
|
45
|
+
</div>
|
|
46
|
+
<p data-explanation="${q.id}" class="mt-3 hidden text-sm"></p>
|
|
47
|
+
</fieldset>
|
|
48
|
+
`
|
|
49
|
+
)
|
|
50
|
+
.join('')}
|
|
51
|
+
<button type="submit" class="btn-primary">Indienen</button>
|
|
52
|
+
</form>
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
container.querySelector('[data-quiz-form]').addEventListener('submit', (e) => {
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
const form = e.target
|
|
58
|
+
const answers = {}
|
|
59
|
+
|
|
60
|
+
quizData.questions.forEach((q) => {
|
|
61
|
+
const selected = form.querySelector(`input[name="${q.id}"]:checked`)
|
|
62
|
+
answers[q.id] = selected ? parseInt(selected.value, 10) : -1
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const correct = quizData.questions.filter((q) => answers[q.id] === q.correct).length
|
|
66
|
+
const total = quizData.questions.length
|
|
67
|
+
const percent = Math.round((correct / total) * 100)
|
|
68
|
+
const passed = percent >= quizData.passScore
|
|
69
|
+
|
|
70
|
+
const result = { correct, total, percent, passed, answers, date: new Date().toISOString() }
|
|
71
|
+
setQuizScore(quizData.title, result)
|
|
72
|
+
|
|
73
|
+
quizData.questions.forEach((q) => {
|
|
74
|
+
const isCorrect = answers[q.id] === q.correct
|
|
75
|
+
const el = container.querySelector(`[data-explanation="${q.id}"]`)
|
|
76
|
+
el.classList.remove('hidden')
|
|
77
|
+
el.classList.add(isCorrect ? 'text-zinc-700' : 'text-zinc-500')
|
|
78
|
+
el.textContent = isCorrect ? `✓ Correct. ${q.explanation}` : `✗ Fout. ${q.explanation}`
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
showResults(container, quizData, result, false, form)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function showResults(container, quizData, result, fromStorage, form = null) {
|
|
86
|
+
const passed = result.passed
|
|
87
|
+
const resultHtml = `
|
|
88
|
+
<div class="card mb-6 ${passed ? 'bg-zinc-50' : ''}">
|
|
89
|
+
<h2 class="text-xl font-medium text-zinc-900">
|
|
90
|
+
${passed ? 'Geslaagd' : 'Nog niet geslaagd'}
|
|
91
|
+
</h2>
|
|
92
|
+
<p class="mt-2 text-zinc-600">
|
|
93
|
+
Score: ${result.correct} / ${result.total} (${result.percent}%)
|
|
94
|
+
— minimaal ${quizData.passScore}% nodig
|
|
95
|
+
</p>
|
|
96
|
+
${fromStorage ? '<p class="mt-2 text-sm text-zinc-500">Eerder resultaat (opgeslagen in browser). <button type="button" data-reset-quiz class="text-link">Opnieuw maken</button></p>' : ''}
|
|
97
|
+
</div>
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
if (fromStorage) {
|
|
101
|
+
container.innerHTML = resultHtml
|
|
102
|
+
container.querySelector('[data-reset-quiz]')?.addEventListener('click', () => {
|
|
103
|
+
removeQuizScore(quizData.title)
|
|
104
|
+
location.reload()
|
|
105
|
+
})
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
form.querySelector('button[type="submit"]').disabled = true
|
|
110
|
+
form.insertAdjacentHTML('beforebegin', resultHtml)
|
|
111
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve root-absolute paths (/pages/…) to URLs that work in dev, preview, and static dist.
|
|
3
|
+
*/
|
|
4
|
+
export function sitePath(href) {
|
|
5
|
+
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#')) {
|
|
6
|
+
return href
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const path = href.startsWith('/') ? href.slice(1) : href
|
|
10
|
+
const loc = decodeURIComponent(window.location.pathname).replace(/\\/g, '/')
|
|
11
|
+
const inPages = /\/pages\/[^/]+\.html$/i.test(loc)
|
|
12
|
+
|
|
13
|
+
if (!inPages) return path
|
|
14
|
+
|
|
15
|
+
if (path === 'index.html') return '../index.html'
|
|
16
|
+
if (path.startsWith('pages/')) return path.slice('pages/'.length)
|
|
17
|
+
return `../${path}`
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const PREFIX = 'grid-module:'
|
|
2
|
+
|
|
3
|
+
export function getItem(key, fallback = null) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = localStorage.getItem(PREFIX + key)
|
|
6
|
+
return raw ? JSON.parse(raw) : fallback
|
|
7
|
+
} catch {
|
|
8
|
+
return fallback
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setItem(key, value) {
|
|
13
|
+
localStorage.setItem(PREFIX + key, JSON.stringify(value))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getChecklistState() {
|
|
17
|
+
return getItem('checklist', {})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setChecklistItem(id, checked) {
|
|
21
|
+
const state = getChecklistState()
|
|
22
|
+
state[id] = checked
|
|
23
|
+
setItem('checklist', state)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getQuizScore(quizId) {
|
|
27
|
+
return getItem(`quiz:${quizId}`, null)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setQuizScore(quizId, score) {
|
|
31
|
+
setItem(`quiz:${quizId}`, score)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function removeQuizScore(quizId) {
|
|
35
|
+
localStorage.removeItem(PREFIX + `quiz:${quizId}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getChecklistProgress(checklistData) {
|
|
39
|
+
const state = getChecklistState()
|
|
40
|
+
let total = 0
|
|
41
|
+
let checked = 0
|
|
42
|
+
|
|
43
|
+
for (const group of checklistData.groups) {
|
|
44
|
+
for (const item of group.items) {
|
|
45
|
+
total++
|
|
46
|
+
if (state[item.id]) checked++
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { total, checked, percent: total ? Math.round((checked / total) * 100) : 0 }
|
|
51
|
+
}
|
package/src/js/theory.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import './components.js'
|
|
2
|
+
|
|
3
|
+
function esc(s) {
|
|
4
|
+
return String(s)
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function initTheory(week) {
|
|
11
|
+
const container = document.querySelector(`[data-theory][data-week="${week}"]`)
|
|
12
|
+
if (!container) return
|
|
13
|
+
|
|
14
|
+
let data
|
|
15
|
+
try {
|
|
16
|
+
data = await import(`../data/theory-week${week}.json`).then((m) => m.default)
|
|
17
|
+
} catch {
|
|
18
|
+
container.innerHTML = `
|
|
19
|
+
<p class="text-red-600">Theorie voor week ${week} niet gevonden.</p>
|
|
20
|
+
<p class="mt-2 text-sm text-zinc-500">Run <code class="font-mono">npm run generate-content</code> om content te genereren.</p>
|
|
21
|
+
`
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!data?.html) {
|
|
26
|
+
container.innerHTML = `
|
|
27
|
+
<p class="text-amber-700">Theorie voor week ${week} is nog leeg.</p>
|
|
28
|
+
<p class="mt-2 text-sm text-zinc-500">Run <code class="font-mono">npm run generate-content</code> om content te genereren.</p>
|
|
29
|
+
`
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
container.innerHTML = `
|
|
34
|
+
<span class="week-label">Week ${data.week}</span>
|
|
35
|
+
<h1 class="text-3xl font-semibold tracking-tight text-zinc-900">${esc(data.title)}</h1>
|
|
36
|
+
<p class="mt-2 text-lg text-zinc-600">Leerdoel: ${data.goal}</p>
|
|
37
|
+
<div class="prose-theory mt-8">
|
|
38
|
+
${data.html}
|
|
39
|
+
</div>
|
|
40
|
+
`
|
|
41
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { getItem, setItem } from './storage.js'
|
|
2
|
+
|
|
3
|
+
function storageKey(week) {
|
|
4
|
+
return `thuiswerk:week${week}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function initThuiswerk(data) {
|
|
8
|
+
const container = document.querySelector('[data-thuiswerk]')
|
|
9
|
+
if (!container) return
|
|
10
|
+
|
|
11
|
+
const state = getItem(storageKey(data.week), { criteria: {}, notes: '', submitted: false })
|
|
12
|
+
|
|
13
|
+
const criteriaHtml = data.criteria
|
|
14
|
+
.map((c) => {
|
|
15
|
+
const checked = !!state.criteria[c.id]
|
|
16
|
+
const optional = c.optional ? ' <span class="text-zinc-400">(bonus)</span>' : ''
|
|
17
|
+
return `
|
|
18
|
+
<label class="flex cursor-pointer items-start gap-3 border-b border-zinc-100 py-3 last:border-0">
|
|
19
|
+
<input
|
|
20
|
+
type="checkbox"
|
|
21
|
+
data-criterion-id="${c.id}"
|
|
22
|
+
class="mt-0.5 h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900"
|
|
23
|
+
${checked ? 'checked' : ''}
|
|
24
|
+
/>
|
|
25
|
+
<span class="flex-1 text-sm text-zinc-700">
|
|
26
|
+
${c.text}${optional}
|
|
27
|
+
<span class="ml-2 font-mono text-xs text-zinc-400">${c.points}p</span>
|
|
28
|
+
</span>
|
|
29
|
+
</label>
|
|
30
|
+
`
|
|
31
|
+
})
|
|
32
|
+
.join('')
|
|
33
|
+
|
|
34
|
+
const deliverablesHtml = data.deliverables
|
|
35
|
+
.map((d) => `<li class="text-sm text-zinc-600">${d}</li>`)
|
|
36
|
+
.join('')
|
|
37
|
+
|
|
38
|
+
const tipsHtml = data.tips.map((t) => `<li class="text-sm text-zinc-600">${t}</li>`).join('')
|
|
39
|
+
|
|
40
|
+
container.innerHTML = `
|
|
41
|
+
<section class="card mb-6">
|
|
42
|
+
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Casus</p>
|
|
43
|
+
<h2 class="mt-2 text-xl font-medium text-zinc-900">${data.client}</h2>
|
|
44
|
+
<p class="mt-4 leading-relaxed text-zinc-600">${data.case}</p>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<section class="card mb-6">
|
|
48
|
+
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Opdracht</p>
|
|
49
|
+
<p class="mt-3 leading-relaxed text-zinc-600">${data.assignment}</p>
|
|
50
|
+
<div class="mt-6">
|
|
51
|
+
<p class="text-sm font-medium text-zinc-900">Inleveren</p>
|
|
52
|
+
<ul class="mt-2 list-inside list-disc space-y-1">${deliverablesHtml}</ul>
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<section class="card mb-6">
|
|
57
|
+
<div class="mb-4 flex items-end justify-between">
|
|
58
|
+
<div>
|
|
59
|
+
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Beoordelingscriteria</p>
|
|
60
|
+
<p class="mt-1 text-sm text-zinc-500">Maximaal ${data.maxPoints} punten — gebruik als checklist vóór je inlevert</p>
|
|
61
|
+
</div>
|
|
62
|
+
<span data-criteria-score class="font-mono text-sm text-zinc-900">0 / ${data.maxPoints}</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div data-criteria-list>${criteriaHtml}</div>
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section class="card mb-6">
|
|
68
|
+
<label class="block">
|
|
69
|
+
<p class="text-sm font-medium text-zinc-900">Notities / link naar je bestanden</p>
|
|
70
|
+
<p class="mt-1 text-sm text-zinc-500">Bijv. GitHub-link, Google Drive, of een korte toelichting voor je docent.</p>
|
|
71
|
+
<textarea
|
|
72
|
+
data-thuiswerk-notes
|
|
73
|
+
class="mt-3 min-h-[120px] w-full border border-zinc-200 bg-white p-4 text-sm text-zinc-700 focus:border-zinc-900 focus:outline-none"
|
|
74
|
+
placeholder="Plak hier je inlever-link of notities..."
|
|
75
|
+
>${state.notes || ''}</textarea>
|
|
76
|
+
</label>
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<section class="card-muted mb-6">
|
|
80
|
+
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Tips</p>
|
|
81
|
+
<ul class="mt-3 list-inside list-disc space-y-1">${tipsHtml}</ul>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<div class="flex flex-wrap gap-3">
|
|
85
|
+
<button type="button" data-export-thuiswerk class="btn-secondary">Exporteer checklist</button>
|
|
86
|
+
<button type="button" data-mark-submitted class="btn-primary">${state.submitted ? 'Ingeleverd ✓' : 'Markeer als ingeleverd'}</button>
|
|
87
|
+
</div>
|
|
88
|
+
<p data-submitted-hint class="mt-3 text-sm text-zinc-500 ${state.submitted ? '' : 'hidden'}">Je hebt deze opdracht gemarkeerd als ingeleverd. Lever ook in via het kanaal van je docent.</p>
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
const saveState = (updates) => {
|
|
92
|
+
const current = getItem(storageKey(data.week), { criteria: {}, notes: '', submitted: false })
|
|
93
|
+
setItem(storageKey(data.week), { ...current, ...updates })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const updateScore = () => {
|
|
97
|
+
const checked = container.querySelectorAll('[data-criterion-id]:checked')
|
|
98
|
+
let points = 0
|
|
99
|
+
checked.forEach((input) => {
|
|
100
|
+
const c = data.criteria.find((x) => x.id === input.dataset.criterionId)
|
|
101
|
+
if (c) points += c.points
|
|
102
|
+
})
|
|
103
|
+
const el = container.querySelector('[data-criteria-score]')
|
|
104
|
+
if (el) el.textContent = `${points} / ${data.maxPoints}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
container.querySelectorAll('[data-criterion-id]').forEach((input) => {
|
|
108
|
+
input.addEventListener('change', () => {
|
|
109
|
+
const criteria = {}
|
|
110
|
+
container.querySelectorAll('[data-criterion-id]').forEach((cb) => {
|
|
111
|
+
criteria[cb.dataset.criterionId] = cb.checked
|
|
112
|
+
})
|
|
113
|
+
saveState({ criteria })
|
|
114
|
+
updateScore()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const notesEl = container.querySelector('[data-thuiswerk-notes]')
|
|
119
|
+
notesEl?.addEventListener('input', () => {
|
|
120
|
+
saveState({ notes: notesEl.value })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
container.querySelector('[data-export-thuiswerk]')?.addEventListener('click', async () => {
|
|
124
|
+
const criteria = getItem(storageKey(data.week), {}).criteria || {}
|
|
125
|
+
const notes = notesEl?.value || ''
|
|
126
|
+
const lines = [
|
|
127
|
+
data.subtitle,
|
|
128
|
+
data.title,
|
|
129
|
+
'='.repeat(40),
|
|
130
|
+
'',
|
|
131
|
+
`Klant: ${data.client}`,
|
|
132
|
+
'',
|
|
133
|
+
'Criteria:',
|
|
134
|
+
]
|
|
135
|
+
for (const c of data.criteria) {
|
|
136
|
+
const mark = criteria[c.id] ? '[x]' : '[ ]'
|
|
137
|
+
lines.push(` ${mark} (${c.points}p) ${c.text}`)
|
|
138
|
+
}
|
|
139
|
+
if (notes) {
|
|
140
|
+
lines.push('', 'Notities:', notes)
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await navigator.clipboard.writeText(lines.join('\n'))
|
|
144
|
+
const btn = container.querySelector('[data-export-thuiswerk]')
|
|
145
|
+
btn.textContent = 'Gekopieerd!'
|
|
146
|
+
setTimeout(() => { btn.textContent = 'Exporteer checklist' }, 2000)
|
|
147
|
+
} catch { /* ignore */ }
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
container.querySelector('[data-mark-submitted]')?.addEventListener('click', () => {
|
|
151
|
+
const current = getItem(storageKey(data.week), { submitted: false })
|
|
152
|
+
const submitted = !current.submitted
|
|
153
|
+
saveState({ submitted })
|
|
154
|
+
const btn = container.querySelector('[data-mark-submitted]')
|
|
155
|
+
const hint = container.querySelector('[data-submitted-hint]')
|
|
156
|
+
btn.textContent = submitted ? 'Ingeleverd ✓' : 'Markeer als ingeleverd'
|
|
157
|
+
hint?.classList.toggle('hidden', !submitted)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
updateScore()
|
|
161
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<meta charset="UTF-8" />
|
|
2
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
4
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
5
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
6
|
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|