diff --git a/_posts/2017-09-19-ops-s1-hello.markdown b/_posts/2017-09-19-ops-s1-hello.markdown index 34a8733..5a454ff 100644 --- a/_posts/2017-09-19-ops-s1-hello.markdown +++ b/_posts/2017-09-19-ops-s1-hello.markdown @@ -242,3 +242,7 @@ Which command lists your Docker images? - (x) docker image ls - ( ) docker run - ( ) docker container ls + +{:.quiz} +What command would you use to inspect "ubuntu" Docker image? +> docker image inspect ubuntu diff --git a/_posts/2017-09-19-ops-s1-swarm-intro.markdown b/_posts/2017-09-19-ops-s1-swarm-intro.markdown index 837f67a..dbcb20e 100644 --- a/_posts/2017-09-19-ops-s1-swarm-intro.markdown +++ b/_posts/2017-09-19-ops-s1-swarm-intro.markdown @@ -197,8 +197,8 @@ What is a stack? {:.quiz} A stack can: -- (x) be deployed from the commandline -- (x) use the compose file format to deploy -- ( ) run a Dockerfile -- ( ) be used to manage your hosts -- (x) be used to manage services over multiple nodes +- \[x] be deployed from the commandline +- \[x] use the compose file format to deploy +- \[ ] run a Dockerfile +- \[ ] be used to manage your hosts +- \[x] be used to manage services over multiple nodes diff --git a/_sass/_custom.scss b/_sass/_custom.scss index 39d03f6..e6cc059 100644 --- a/_sass/_custom.scss +++ b/_sass/_custom.scss @@ -1,3 +1,8 @@ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 16px; +} + .panel-left .container { width: 100% !important; } diff --git a/css/quiz.css b/css/quiz.css index 26c0ff1..6302081 100644 --- a/css/quiz.css +++ b/css/quiz.css @@ -1,41 +1,161 @@ -/* Carousel base class */ -.carousel { - height: 500px; - margin-bottom: 60px; +:root { + --primary-color: #0083da; + --primary-highlight-color: #056fb6; + --primary-text-color: #000000; + --background-color: #ffffff; + --background-color2: #97cfec; + --second-bg-color: rgb(244, 244, 244); + --wrong-choice-color: red; + --wrong-choice-color2: rgb(255, 231, 231); + --correct-choice-color: green; + --correct-choice-color2: rgb(164, 238, 201); } -/* Since positioning the image, we need to help out the caption */ -.carousel-caption { - z-index: 10; + +:root[data-theme='dark'], +:root[data-force-color-mode="dark"] { + --primary-color: #4a6572; + --background-color: #eeeeee; + --second-bg-color: #dddddd; } -/* Declare heights because of positioning of img element */ -.carousel .item { - height: 500px; - background-color: #777; +body { + background-color: var(--background-color); + color: var(--primary-text-color); } -.carousel-inner > .item > img { - position: absolute; - top: 0; - left: 0; - min-width: 100%; - height: 500px; + +.quiz-title { + font-weight: 400; + font-size: 20px; + line-height: 22px; + margin: 10px 0px 5px 0px; } -.carousel-caption { - text-align: left; - top: 0%; +.quiz-question-content { + display: flex; + flex-flow: column; + gap: 10px; } -.carousel-caption label { - margin-bottom: 20px; - font-size: 21px; - line-height: 1; +.quiz-question-content.open-ended { + flex-flow: row; } -.quiz-success { - color: #5cb85c; +.quiz-question-content input.answer { + flex: 3 1 0%; + border: none; + border-radius: 4px; + padding: 10px; + background: var(--second-bg-color); } -.quiz-error { - color: #d9534f; +.quiz-question-content input.answer:focus { + border: none !important; } + +.quiz-question-content button.check { + border: none; + border-radius: 4px; + min-height: 30px; + background-color: var(--primary-color); + flex-grow: 1; + max-width: 40%; + cursor: pointer; + color: var(--background-color); + font-size: 14px; + font-weight: 600; +} + +.quiz-question-content button.check:hover { + background: var(--primary-highlight-color); +} + +.quiz-question-content button.check:active { + transform: translateY(1px); +} + +input[type="radio"], +input[type="checkbox"] { + font: inherit; + width: 1em; + height: 1em; + border: 0.15em solid currentColor; + border-radius: 0.15em; + transform: translateY(-0.075em); + place-content: center; + vertical-align: middle; + position: relative; + z-index: 1; + margin: 15px; + /* I am not happy with this solution, but putting checkbox inside of the + label, prevents checkbox:checked ~ .control-label from working. */ + margin-bottom: -45px; + display: none; +} + +input[type="checkbox"]:checked::before { + transform: scale(1); +} + +.control-label { + box-shadow: none; + font-size: 1em; + display: flex; + vertical-align: middle; + border: var(--second-bg-color) solid 1px; + border-radius: 4px; + background: var(--second-bg-color); + padding: 10px; + /* padding-left: 50px; */ + cursor: pointer; + font-weight: 400; +} + +.control-label:hover { + border: 1px solid var(--primary-color); + background: var(--background-color2); +} + +input[type="checkbox"]:checked~.control-label, +input[type="radio"]:checked~.control-label { + font-weight: 600; + transform: scale(1); + transition: 520ms transform ease-in-out; + border: 1px solid var(--primary-color); +} + +.no-select { + user-select: none; +} + +.post-content { + display: flex; + flex-direction: column; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.wrong-choice { + color: var(--wrong-choice-color); + background: var(--wrong-choice-color2); +} + +.correct-choice { + color: var(--correct-choice-color); + background: var(--correct-choice-color2); +} + +.chosen-choice { + border: currentColor solid 1px !important; + border-radius: 4px; +} + +.question-description { + color: gray; + font-size: 14px; +} \ No newline at end of file diff --git a/js/quiz.js b/js/quiz.js index 727bbb2..a200319 100644 --- a/js/quiz.js +++ b/js/quiz.js @@ -1,117 +1,110 @@ "use strict"; -addEventListener("load", () => { - let questions = {}; - const paragraphs = document.querySelectorAll('p.quiz'); +/** + * This script dynamically generates a quiz interface + * based on HTML markup generated from Markdown by Jekyll. + * + * Amount of possible choices is arbitrary. + * + * Usage: + * Markdown syntax for questions: + * For single-choice questions: + * Question + * - ( ) Wrong choice + * - (x) Correct choice + * - ( ) Wrong choice + * + * For multi-choice questions: + * Question + * - [ ] Wrong choice + * - [x] Correct choice + * - [x] Correct choice + * + * For open ended questions: + * Question + * > Expected answer + * + * Note: don't mix choice types, it will lead to parsing errors and choice will be ignored + * Note: validation results will be logged to the browser console. + */ + +// test +// document.documentElement.setAttribute('data-theme', 'dark'); +{ + let questions = []; + + function getQuestionContentId(questionIndex) { + return `question-${questionIndex}-content`; + } - if (paragraphs.length == 0) { - return; + // Case-insensitive string comparison. Returns true on match. + function strcmpi(a, b) { + return typeof a === 'string' && typeof b === 'string' + ? a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0 + : a === b; } - const carouselHTML = ` - - `; - - document.querySelector('.post-content').insertAdjacentHTML('beforeend', carouselHTML); - - paragraphs.forEach((paragraph, index) => { - const options = paragraph.nextElementSibling; - - if (!options || options.tagName !== 'UL') { - return; + + function validateQuestion(event) { + const buttonElement = event.target; + const questionId = buttonElement.dataset.questionId; + const questionContextElement = document.getElementById(getQuestionContentId(questionId)); + const questionMetadata = questions[questionId]; + + const isOpenEnded = questionContextElement.classList.contains('open-ended'); + if (isOpenEnded) { + return validateQuestionWithAnswer(questionContextElement, questionMetadata); + } else { + return validateQuestionWithChoices(questionContextElement, questionMetadata); } + } - const question = paragraph.textContent; - paragraph.outerHTML = ` -
-
- -
-
- `; - const formGroup = document.getElementById(`question${index}`); - - options.querySelectorAll('li').forEach(option => { - const text = option.textContent.trim(); - option.textContent = ''; - - if (text.startsWith('[ ]')) { - formGroup.insertAdjacentHTML('beforeend', `
`); - } else if (text.startsWith('[x]')) { - formGroup.insertAdjacentHTML('beforeend', `
`); - questions[`question${index}`] = { expected: 1, correct: 0, wrong: 0 }; - } else if (text.startsWith('( )')) { - formGroup.insertAdjacentHTML('beforeend', `
`); - } else if (text.startsWith('(x)')) { - formGroup.insertAdjacentHTML('beforeend', `
`); - questions[`question${index}`] = { expected: 1, correct: 0, wrong: 0 }; - } - }); + function validateQuestionWithAnswer(questionContextElement, questionMetadata) { + console.log(questionContextElement, questionMetadata); + const answerElement = questionContextElement.querySelector("input.answer"); + const givenAnswer = answerElement.value; + const expectedAnswer = questionMetadata.expectedAnswer; + + if (strcmpi(givenAnswer, expectedAnswer)) { + answerElement.classList.add("correct-choice"); + answerElement.disabled = true; + } else { + answerElement.classList.add("wrong-choice"); + } - options.remove(); - document.querySelector('.carousel-indicators').insertAdjacentHTML('beforeend', `
  • `); - }); - - document.querySelectorAll('.item').forEach(item => { - item.parentNode.querySelector('.carousel-inner').appendChild(item); - }); - - document.querySelector('.item').classList.add('active'); - document.querySelector('.item:last-child .carousel-caption').insertAdjacentHTML('beforeend', '


    Submit your answers

    '); - document.querySelector('ol.carousel-indicators li').classList.add('active'); - document.querySelector('a.submit-quiz').addEventListener('click', validateQuiz); - - function validateQuiz() { - document.querySelectorAll('input.possible-answer').forEach(input => { - const self = input; - const parent = self.parentElement.parentElement; - const dataQuestion = self.getAttribute('data-question'); - const dataAnswer = self.getAttribute('data-answer'); - - if (self.checked && dataAnswer === 'false') { - parent.classList.add('quiz-error'); - questions[dataQuestion].wrong++; - } else if (self.checked && dataAnswer === 'true') { - questions[dataQuestion].correct++; - } - if (dataAnswer === 'true') { - parent.classList.add('quiz-success'); - } - self.disabled = true; - }); + answerElement.classList.add("font-semibold", "chosen-choice"); + answerElement.addEventListener("input", function () { + answerElement.classList.remove("wrong-choice", "font-semibold"); + }, { once: true }); + } - const result = { correct: 0, total: 0 }; + function validateQuestionWithChoices(questionContextElement, questionMetadata) { + questionContextElement.querySelectorAll('.choice').forEach((choiceElement, choiceIndex) => { + const checkboxElement = choiceElement.querySelector('input'); + const labelElement = choiceElement.querySelector('label'); + const choice = checkboxElement.checked; + const expectedChoice = questionMetadata.expectedAnswer[choiceIndex]; - for (const key in questions) { - const q = questions[key]; + if (choice) labelElement.classList.add("chosen-choice"); - result.total++; - if (q.wrong === 0 && q.correct === q.expected) { - result.correct++; + if (choice && !expectedChoice) { + labelElement.classList.add("wrong-choice"); + // mark up only "active" (checked) correct answers + } else if (expectedChoice) { + labelElement.classList.add("correct-choice"); } - } + checkboxElement.disabled = true; + }); + } + function addTwitterShareButton() { const title = document.querySelector('.post-title').textContent; const hashtags = 'dockerbday'; const text = encodeURIComponent(`I've just completed the docker birthday tutorial ${title} and got ${result.correct} out of ${result.total}`); const submitQuizLink = document.querySelector('a.submit-quiz'); submitQuizLink.outerHTML = `

    You've got ${result.correct} out of ${result.total}

    -
    Tweet`; + Tweet`; fetch('https://platform.twitter.com/widgets.js') .then(response => { @@ -131,4 +124,105 @@ addEventListener("load", () => { } -}); + function render() { + const QuestionTypes = Object.freeze({ + Undetermined: -1, + SingleChoice: 0, + MultiChoice: 1, + OpenEnded: 2, + }); + + const questionHeaders = document.querySelectorAll('p.quiz'); + if (questionHeaders.length == 0) { + return; + } + + // Render all question based on server-side rendered HTML based on Markdown + questionHeaders.forEach((questionHeader, questionIndex) => { + const answerField = questionHeader.nextElementSibling; + + if (!answerField || (answerField.tagName !== 'UL' && answerField.tagName !== 'BLOCKQUOTE')) { + console.error(`Invalid answer field: "${answerField}".`); + return; + } + + const questionContentId = getQuestionContentId(questionIndex); + const questionText = questionHeader.textContent.trim(); + questionHeader.outerHTML = ` +
    +
    ${questionText}
    +
    +
    + `; + const questionContextElement = document.getElementById(questionContentId); + + questions[questionIndex] = { + type: QuestionTypes.Undetermined, + expectedAnswer: [], + givenAnswer: [], + isAnswered: false + }; + const questionMetadata = questions[questionIndex]; + + // Open-ended question + if (answerField.tagName === 'BLOCKQUOTE') { + questionMetadata.type = QuestionTypes.OpenEnded; + questionMetadata.expectedAnswer = answerField.textContent.trim(); + + const html = ``; + questionContextElement.insertAdjacentHTML('beforeend', html); + questionContextElement.classList.add("open-ended"); + } else { + // Question with choice + answerField.querySelectorAll('li').forEach((choice, choiceIndex) => { + const text = choice.textContent.trim(); + const choiceId = `choice-${questionIndex}-${choiceIndex}`; + const choiceName = `choice-${questionIndex}`; + + const prefixOptionsWhitelist = ['[ ]', '[x]', '( )', '(x)'] + const textPrefix = text.slice(0, 3); + + if (!prefixOptionsWhitelist.includes(textPrefix)) { + console.error(`Invalid question format: "${text}"`); + return; + } + + const choiceType = textPrefix[0] == '[' ? QuestionTypes.MultiChoice : QuestionTypes.SingleChoice; + if (questionMetadata['type'] !== QuestionTypes.Undetermined && questionMetadata['type'] != choiceType) { + console.error(`Mixed question type: "${text}". Previous choice: ${questionMetadata['type']}`); + return; + } + questionMetadata['type'] = choiceType; + // TODO: check for more than 1 "correct" choices for single-choice question + + const htmlType = choiceType ? "checkbox" : "radio"; + questionMetadata['expectedAnswer'][choiceIndex] = textPrefix[1] == 'x'; + + const html = ` +
    + + + +
    `; + questionContextElement.insertAdjacentHTML('beforeend', html); + }); + const questionMetaDescription = questionMetadata['type'] === QuestionTypes.MultiChoice ? 'Multiple Choice Question (Select all that apply)' : 'Single Choice Question'; + const descriptionHTML = `
    ${questionMetaDescription}
    `; + questionContextElement.insertAdjacentHTML('beforebegin', descriptionHTML); + } + + const buttonElementId = `check-button-${questionIndex}`; + const verifyActionButtonHTML = ``; + questionContextElement.insertAdjacentHTML('beforeend', verifyActionButtonHTML); + document.getElementById(buttonElementId).addEventListener('click', validateQuestion); + + answerField.remove(); + }); + + } + + addEventListener("load", render); +} \ No newline at end of file