Skip to content

Commit 8131ee4

Browse files
committed
Use HTMX to dynamically refresh the challenge area, while keeping the sidebar unchanged
1 parent abaade0 commit 8131ee4

File tree

8 files changed

+145
-100
lines changed

8 files changed

+145
-100
lines changed

pdm.lock

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dependencies = [
1111
"pyright>=1.1.338",
1212
"flask-sitemapper>=1.7.0",
1313
"markdown>=3.5.1",
14+
"flask-htmx>=0.3.2",
1415
]
1516
requires-python = ">=3.12"
1617
readme = "README.md"

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
blinker==1.6.3
55
click==8.1.7
66
flask==3.0.0
7+
flask-htmx==0.3.2
78
flask-sitemapper==1.7.0
89
itsdangerous==2.1.2
910
jinja2==3.1.2
@@ -14,4 +15,3 @@ pyright==1.1.338
1415
setuptools==68.2.2
1516
typing-extensions==4.8.0
1617
Werkzeug==3.0.0
17-

templates/base.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
</style>
3232
<script data-goatcounter="https://laike9m.goatcounter.com/count"
3333
async src="{{url_for('static', filename='js/goatcounter.js')}}")></script>
34+
<script src="https://cdnjs.cloudflare.com/ajax/libs/htmx/1.9.10/htmx.min.js"
35+
crossorigin="anonymous" referrerpolicy="no-referrer">
36+
</script>
3437
</head>
3538

3639
<body>

templates/challenge.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,5 @@
265265
{% include "components/challenge_area.html" %}
266266
</div>
267267
</div>
268+
268269
{% endblock %}

templates/components/challenge_area.html

Lines changed: 91 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -48,102 +48,106 @@
4848
</div>
4949
</div>
5050

51-
5251
<script type="text/javascript">
53-
let confetti = new JSConfetti();
54-
let initTheme = localStorage.getItem('theme') === 'dark' ? "material-darker" : "default"
55-
let sharedCodeMirrorOptions = {
56-
mode: "python",
57-
lineWrapping: true,
58-
lineNumbers: true,
59-
indentUnit: 4,
60-
theme: initTheme,
61-
}
62-
let code_under_test = {{ code_under_test | tojson }};
63-
let myCodeMirror = CodeMirror(document.getElementById("editor"), {
64-
value: code_under_test,
65-
...sharedCodeMirrorOptions,
66-
});
67-
let test_code = {{ test_code | tojson }};
68-
let testCodeMirror = CodeMirror(document.getElementById("tests"), {
69-
value: test_code,
70-
readOnly: "nocursor",
71-
...sharedCodeMirrorOptions,
72-
});
52+
function renderCodeArea(code_under_test, test_code, level, name) {
53+
let confetti = new JSConfetti();
54+
let initTheme = localStorage.getItem('theme') === 'dark' ? "material-darker" : "default"
55+
let sharedCodeMirrorOptions = {
56+
mode: "python",
57+
lineWrapping: true,
58+
lineNumbers: true,
59+
indentUnit: 4,
60+
theme: initTheme,
61+
}
62+
let myCodeMirror = CodeMirror(document.getElementById("editor"), {
63+
value: code_under_test,
64+
...sharedCodeMirrorOptions,
65+
});
66+
let testCodeMirror = CodeMirror(document.getElementById("tests"), {
67+
value: test_code,
68+
readOnly: "nocursor",
69+
...sharedCodeMirrorOptions,
70+
});
7371

74-
let playgroundLink = document.getElementById("playground-link");
75-
playgroundLink.addEventListener('click', function (event) {
76-
const code = myCodeMirror.getValue() + testCodeMirror.getValue();
77-
this.href = "https://pyright-play.net/?code=" + LZString.compressToEncodedURIComponent(code);
78-
});
72+
let playgroundLink = document.getElementById("playground-link");
73+
playgroundLink.addEventListener('click', function (event) {
74+
const code = myCodeMirror.getValue() + testCodeMirror.getValue();
75+
this.href = "https://pyright-play.net/?code=" + LZString.compressToEncodedURIComponent(code);
76+
});
7977

80-
window.addEventListener('themeSwitch', function (event) {
81-
let theme = localStorage.getItem('theme') === 'dark' ? "material-darker" : "default"
82-
myCodeMirror.setOption("theme", theme);
83-
testCodeMirror.setOption("theme", theme);
84-
})
78+
window.addEventListener('themeSwitch', function (event) {
79+
let theme = localStorage.getItem('theme') === 'dark' ? "material-darker" : "default"
80+
myCodeMirror.setOption("theme", theme);
81+
testCodeMirror.setOption("theme", theme);
82+
})
8583

86-
let runButton = document.getElementById('run-button')
87-
runButton.onclick = function () {
88-
console.log(`Run challenge at: ${new Date().toLocaleString()}`);
84+
let runButton = document.getElementById('run-button')
85+
runButton.onclick = function () {
86+
console.log(`Run challenge at: ${new Date().toLocaleString()}`);
8987

90-
// set button spinner
91-
let rawInnerText = runButton.innerText;
92-
runButton.ariaBusy = true;
93-
runButton.innerText = ""
88+
// set button spinner
89+
let rawInnerText = runButton.innerText;
90+
runButton.ariaBusy = true;
91+
runButton.innerText = ""
9492

95-
let code = myCodeMirror.getValue();
96-
fetch('/run/{{level}}/{{name}}', {
97-
method: 'POST',
98-
body: code
99-
})
100-
.then(response => response.json())
101-
.then(json => {
102-
// add confetti effect when passed
103-
if (json.passed) {
104-
confetti.addConfetti()
105-
}
106-
setTimeout(() => {
107-
document.getElementById('answer-link').style.display = 'block';
108-
}, 500);
109-
document.getElementById("result").innerHTML = json.message;
110-
if (json.debug_info !== undefined) {
111-
console.log(json.debug_info);
112-
}
113-
})
114-
.catch((error) => {
115-
console.error('Error:', error);
93+
let code = myCodeMirror.getValue();
94+
fetch(`/run/${level}/${name}`, {
95+
method: 'POST',
96+
body: code
11697
})
117-
.finally(() => {
118-
// reset button spinner
119-
runButton.ariaBusy = false;
120-
runButton.innerText = rawInnerText;
121-
});
122-
};
98+
.then(response => response.json())
99+
.then(json => {
100+
// add confetti effect when passed
101+
if (json.passed) {
102+
confetti.addConfetti()
103+
}
104+
setTimeout(() => {
105+
document.getElementById('answer-link').style.display = 'block';
106+
}, 500);
107+
document.getElementById("result").innerHTML = json.message;
108+
if (json.debug_info !== undefined) {
109+
console.log(json.debug_info);
110+
}
111+
})
112+
.catch((error) => {
113+
console.error('Error:', error);
114+
})
115+
.finally(() => {
116+
// reset button spinner
117+
runButton.ariaBusy = false;
118+
runButton.innerText = rawInnerText;
119+
});
120+
};
123121

124-
let resetButton = document.getElementById('reset-button')
125-
resetButton.onclick = function () {
126-
myCodeMirror.setValue(code_under_test);
127-
};
122+
let resetButton = document.getElementById('reset-button')
123+
resetButton.onclick = function () {
124+
myCodeMirror.setValue(code_under_test);
125+
};
128126

129-
// Set up hints events and functions
130-
let hintBtn = document.getElementById('read-hints')
131-
hintBtn.onclick = function () {
132-
// Toggle the display of the hints message.
133-
let msgElem = document.getElementsByClassName('hints-message')[0];
134-
if (msgElem.style.display === 'block') {
135-
msgElem.style.display = 'none';
136-
} else {
137-
msgElem.style.display = 'block';
127+
// Set up hints events and functions
128+
let hintBtn = document.getElementById('read-hints')
129+
if (hintBtn !== null) {
130+
hintBtn.onclick = function () {
131+
// Toggle the display of the hints message.
132+
let msgElem = document.getElementsByClassName('hints-message')[0];
133+
if (msgElem.style.display === 'block') {
134+
msgElem.style.display = 'none';
135+
} else {
136+
msgElem.style.display = 'block';
137+
}
138+
};
139+
// Make sure to open links in hints in new tab.
140+
document.querySelectorAll('.hints-message a').forEach(function(elem) {
141+
elem.setAttribute('target', '_blank');
142+
})
138143
}
139-
};
140-
// Make sure to open links in hints in new tab.
141-
document.querySelectorAll('.hints-message a').forEach(function(elem) {
142-
elem.setAttribute('target', '_blank');
143-
})
144144

145-
// Make sure the current challenge is visible to user.
146-
activeChallengeInList = document.getElementById("{{level}}-{{name}}");
147-
activeChallengeInList.scrollIntoView({block: 'center'});
148-
activeChallengeInList.classList.add('active-challenge'); // Highlight
145+
// Make sure the current challenge is visible to user.
146+
activeChallengeInList = document.getElementById(`${level}-${name}`);
147+
activeChallengeInList.classList.add('active-challenge'); // Highlight
148+
}
149+
150+
codeUnderTest = {{code_under_test | tojson}};
151+
testCode = {{ test_code | tojson }};
152+
renderCodeArea(codeUnderTest, testCode, "{{level}}", "{{name}}");
149153
</script>

templates/components/challenge_sidebar.html

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,16 @@ <h5 class="challenge-level">{{ level }}</h5>
108108
</li>
109109
{% endif %}
110110
{% for name in challenge_names %}
111-
<li><a id="{{ level }}-{{ name }}" href="/{{ level }}/{{ name }}">{{ name }}</a></li>
111+
<li>
112+
<!-- Use HTMX to replace the challenge area -->
113+
<a id="{{ level }}-{{ name }}"
114+
hx-get="/{{ level }}/{{ name }}"
115+
hx-target=".challenge-area"
116+
hx-push-url="true"
117+
onclick="removeHighlight(event)">
118+
{{ name }}
119+
</a>
120+
</li>
112121
{% endfor %}
113122
</ul>
114123
{% endfor %}
@@ -124,4 +133,12 @@ <h5 class="challenge-level">{{ level }}</h5>
124133
drawer.classList.toggle('open');
125134
});
126135
});
136+
137+
function removeHighlight(event) {
138+
previousActiveChallenges = document.getElementsByClassName("active-challenge");
139+
for (c of previousActiveChallenges) {
140+
// Remove previously highlighted challenge in the list.
141+
c.classList.remove('active-challenge');
142+
}
143+
}
127144
</script>

views/views.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
render_template,
1111
request,
1212
)
13+
from flask_htmx import HTMX
1314

1415
from .challenge import ChallengeKey, Level, challenge_manager
1516
from .sitemap import sitemapper
1617
from .utils.text import render_hints
1718

1819
app_views = Blueprint("app_views", __name__)
20+
htmx = HTMX(app_views)
1921

2022

2123
def validate_challenge(view_func):
@@ -52,16 +54,20 @@ def index():
5254
@validate_challenge
5355
def get_challenge(level: str, name: str):
5456
challenge = challenge_manager.get_challenge(ChallengeKey(Level(level), name))
55-
return render_template(
56-
"challenge.html",
57-
name=name,
58-
level=challenge.level,
59-
challenges_groupby_level=challenge_manager.challenges_groupby_level,
60-
code_under_test=challenge.user_code,
61-
test_code=challenge.test_code,
62-
hints_for_display=render_hints(challenge.hints) if challenge.hints else None,
63-
python_info=platform.python_version(),
64-
)
57+
params = {
58+
"name": name,
59+
"level": challenge.level,
60+
"challenges_groupby_level": challenge_manager.challenges_groupby_level,
61+
"code_under_test": challenge.user_code,
62+
"test_code": challenge.test_code,
63+
"hints_for_display": render_hints(challenge.hints) if challenge.hints else None,
64+
"python_info": platform.python_version(),
65+
}
66+
if htmx:
67+
# In this case, challenges_groupby_level is transferred, since it's not
68+
# used in challenge_area.html
69+
return render_template("components/challenge_area.html", **params)
70+
return render_template("challenge.html", **params)
6571

6672

6773
@app_views.route("/run/<level>/<name>", methods=["POST"])

0 commit comments

Comments
 (0)