Skip to content

Commit f99c3ea

Browse files
authored
💻 adventure tabs and editor in public adventures page (#4990)
Fixes #4954 # Features Initially the page shows all available public adventures. ![image](https://github.com/hedyorg/hedy/assets/20051470/66d39d18-4d5d-4346-9468-d09bebe45342) There are four filters that teachers can utilize in order to find adventures quickly. These filters are: 1. search input: finds an adventure whose name matches the search input 2. level (default=1): gets adventures of a specific level. Only one item can be selected. 3. language: gets adventures of a specific language. Only one item can be selected. 4. tags: gets adventures that have at least one of the selected tags. Multiple items can be selected. Another interesting and important feature is that the URL always updates wrt the filters. For instance, if you filter by English, the URL will be something like: public-adventures?level=1&**lang=en**&tag=&search=. The same applied to all available filters. Additionally, this means that you can share a url with someone and they would see the exact same adventures you've filtered. So, if you share `/public-adventures?level=1&lang=&tag=print&search=parr` with another teacher, they will see exactly what you see: ![image](https://github.com/hedyorg/hedy/assets/20051470/e1707e39-d041-4ceb-a64f-9e7fdc127d9c) # **How to test?** Go to `/public-adventures` and start applying some filters, run some code of any adventure you like, and perhaps clone one that's created by some other user. # **Technicality (for devs)** Whenever a filter is applied, we send a request to python and expect two things: - `html`: the template of the new filtered adventures, to replace the current adventure tabs with. - `js`: some properties that are needed for js; e.g., to initialize the editor. The html is simply a string that we replace the innerHTML of the target element that includes the editor and tabs. And since this is a string, we need to manually initialize the JS code; e.g., run the `initializeHighlightedCodeBlocks` or initialize the editor with the selected level. The reason why I didn't use htmx here is due to the fact that the created template that has the editor and tabs (i.e., in `public-adventures-body.html`) does not issue a rerender on the js side. Most probably, this behavior is because htmx only replaces a target element by whatever the server returns and does not mind the `js` property that we pass to the template. As a result, the editor and tabs become just views with no interactivity. Another matter is the usage of Tailwind Elements. I used this for two reasons: it uses Tailwind (which we also do) and it has a plenty of already styled elements. The problem with TE is that they don't support RTL currently, [however, they did mention that they're working on it](mdbootstrap/TW-Elements#2264 (comment)). So, let's just keep using our custom select that I created, until they fix that issue or we need some of the beautiful components they provide.
1 parent 8226e50 commit f99c3ea

21 files changed

+703
-287
lines changed

data-for-testing.json

+19-1
Original file line numberDiff line numberDiff line change
@@ -1460,8 +1460,26 @@
14601460
"creator": "teacher1",
14611461
"name": "adventure1",
14621462
"level": "1",
1463+
"levels": ["1", "2"],
14631464
"content": "This is the explanation of my adventure!\n\nThis way I can show a command: <code>print</code>\n\nBut sometimes I might want to show a piece of code, like this:\n<pre class=\"no-copy-button\">ask What's your name?\necho so your name is \n</pre>",
1464-
"public": true
1465+
"public": true,
1466+
"tags": [
1467+
"test"
1468+
]
1469+
}
1470+
],
1471+
"tags": [
1472+
{
1473+
"id": "01f34a6453ff420a8fb83a3fc0f18d8d",
1474+
"name": "test",
1475+
"tagged_in": [
1476+
{
1477+
"id": "3f8aea42eb324f08a16776671498dd1b",
1478+
"public": true,
1479+
"language": "en"
1480+
}
1481+
],
1482+
"popularity": 1
14651483
}
14661484
]
14671485
}

static/css/additional.css

+19
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,22 @@ div[data-te-input-notch-ref] {
225225
font-weight: bold;
226226
background-color: #edf2f7;
227227
}
228+
229+
.option {
230+
padding: 8px;
231+
cursor: pointer;
232+
233+
&.selected {
234+
background-color: rgb(0 0 0 / 0.05);
235+
}
236+
237+
&:not(.default).selected::after {
238+
content: "✓";
239+
margin: 1em;
240+
}
241+
242+
&:hover {
243+
background-color: rgb(0 0 0 / 0.05);
244+
}
245+
246+
}

static/css/generated.full.css

+4
Original file line numberDiff line numberDiff line change
@@ -346427,6 +346427,10 @@ div[class^="ace_incorrect_hedy_code"] {
346427346427
width: 41.666667%;
346428346428
}
346429346429

346430+
.sm\:basis-1\/2 {
346431+
flex-basis: 50%;
346432+
}
346433+
346430346434
.sm\:flex-row {
346431346435
flex-direction: row;
346432346436
}

static/js/app.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2088,7 +2088,7 @@ async function saveIfNecessary() {
20882088
const saveName = saveNameFromInput();
20892089

20902090

2091-
if (theUserIsLoggedIn) {
2091+
if (theUserIsLoggedIn && saveName) {
20922092
const saveInfo = isServerSaveInfo(adventure.save_info) ? adventure.save_info : undefined;
20932093
const response = await postJsonWithAchievements('/programs', {
20942094
level: theLevel,

static/js/appbundle.js

+130-88
Original file line numberDiff line numberDiff line change
@@ -4079,12 +4079,10 @@ var hedyApp = (() => {
40794079
InitLineChart: () => InitLineChart,
40804080
add_account_placeholder: () => add_account_placeholder,
40814081
append_classname: () => append_classname,
4082-
applyFilter: () => applyFilter,
40834082
changeUserEmail: () => changeUserEmail,
40844083
change_language: () => change_language,
40854084
change_password_student: () => change_password_student,
40864085
clearUnsavedChanges: () => clearUnsavedChanges,
4087-
cloned: () => cloned,
40884086
closeAchievement: () => closeAchievement,
40894087
closeContainingModal: () => closeContainingModal,
40904088
comeBackHereAfterLogin: () => comeBackHereAfterLogin,
@@ -45815,9 +45813,9 @@ notes_mapping = {
4581545813
function styleTags(spec) {
4581645814
let byName = Object.create(null);
4581745815
for (let prop in spec) {
45818-
let tags3 = spec[prop];
45819-
if (!Array.isArray(tags3))
45820-
tags3 = [tags3];
45816+
let tags2 = spec[prop];
45817+
if (!Array.isArray(tags2))
45818+
tags2 = [tags2];
4582145819
for (let part of prop.split(" "))
4582245820
if (part) {
4582345821
let pieces = [], mode = 2, rest = part;
@@ -45845,16 +45843,16 @@ notes_mapping = {
4584545843
let last = pieces.length - 1, inner = pieces[last];
4584645844
if (!inner)
4584745845
throw new RangeError("Invalid path: " + part);
45848-
let rule = new Rule(tags3, mode, last > 0 ? pieces.slice(0, last) : null);
45846+
let rule = new Rule(tags2, mode, last > 0 ? pieces.slice(0, last) : null);
4584945847
byName[inner] = rule.sort(byName[inner]);
4585045848
}
4585145849
}
4585245850
return ruleNodeProp.add(byName);
4585345851
}
4585445852
var ruleNodeProp = new NodeProp();
4585545853
var Rule = class {
45856-
constructor(tags3, mode, context2, next) {
45857-
this.tags = tags3;
45854+
constructor(tags2, mode, context2, next) {
45855+
this.tags = tags2;
4585845856
this.mode = mode;
4585945857
this.context = context2;
4586045858
this.next = next;
@@ -45878,9 +45876,9 @@ notes_mapping = {
4587845876
}
4587945877
};
4588045878
Rule.empty = new Rule([], 2, null);
45881-
function tagHighlighter(tags3, options) {
45879+
function tagHighlighter(tags2, options) {
4588245880
let map3 = Object.create(null);
45883-
for (let style of tags3) {
45881+
for (let style of tags2) {
4588445882
if (!Array.isArray(style.tag))
4588545883
map3[style.tag.id] = style.class;
4588645884
else
@@ -45889,9 +45887,9 @@ notes_mapping = {
4588945887
}
4589045888
let { scope, all = null } = options || {};
4589145889
return {
45892-
style: (tags4) => {
45890+
style: (tags3) => {
4589345891
let cls = all;
45894-
for (let tag of tags4) {
45892+
for (let tag of tags3) {
4589545893
for (let sub of tag.set) {
4589645894
let tagClass = map3[sub.id];
4589745895
if (tagClass) {
@@ -45905,10 +45903,10 @@ notes_mapping = {
4590545903
scope
4590645904
};
4590745905
}
45908-
function highlightTags(highlighters, tags3) {
45906+
function highlightTags(highlighters, tags2) {
4590945907
let result = null;
4591045908
for (let highlighter of highlighters) {
45911-
let value = highlighter.style(tags3);
45909+
let value = highlighter.style(tags2);
4591245910
if (value)
4591345911
result = result ? result + " " + value : value;
4591445912
}
@@ -48967,13 +48965,13 @@ notes_mapping = {
4896748965
var openSearchPanel = (view) => {
4896848966
let state = view.state.field(searchState, false);
4896948967
if (state && state.panel) {
48970-
let searchInput = getSearchInput(view);
48971-
if (searchInput && searchInput != view.root.activeElement) {
48968+
let searchInput2 = getSearchInput(view);
48969+
if (searchInput2 && searchInput2 != view.root.activeElement) {
4897248970
let query = defaultQuery(view.state, state.query.spec);
4897348971
if (query.valid)
4897448972
view.dispatch({ effects: setSearchQuery.of(query) });
48975-
searchInput.focus();
48976-
searchInput.select();
48973+
searchInput2.focus();
48974+
searchInput2.select();
4897748975
}
4897848976
} else {
4897948977
view.dispatch({ effects: [
@@ -58322,7 +58320,7 @@ pygame.quit()
5832258320
console.info("Saving program automatically...");
5832358321
const code = theGlobalEditor.contents;
5832458322
const saveName = saveNameFromInput();
58325-
if (theUserIsLoggedIn) {
58323+
if (theUserIsLoggedIn && saveName) {
5832658324
const saveInfo = isServerSaveInfo(adventure.save_info) ? adventure.save_info : void 0;
5832758325
const response = await postJsonWithAchievements("/programs", {
5832858326
level: theLevel,
@@ -76848,82 +76846,126 @@ pygame.quit()
7684876846
ZC({ Validation: Ch, Select: _r });
7684976847

7685076848
// static/js/public-adventures.ts
76851-
function cloned(message, success2 = true) {
76852-
if (success2) {
76853-
modal.notifySuccess(message);
76854-
} else {
76855-
modal.notifyError(message);
76856-
}
76857-
}
76858-
function applyFilter(term, type, filtered) {
76859-
var _a3, _b;
76860-
term = term.trim();
76861-
filtered[type] = filtered[type] || { exclude: [] };
76862-
filtered[type]["term"] = term;
76863-
const filterExist = document.querySelector("#search_adventure").value || document.querySelector("#language").value || document.querySelector("#tag").value;
76864-
if (!term) {
76865-
filtered[type] = { term, exclude: [] };
76866-
}
76867-
const adventures = document.querySelectorAll(".adventure");
76868-
if (!filterExist) {
76869-
for (const adv of adventures) {
76870-
adv.classList.remove("hidden");
76871-
}
76872-
filtered = {};
76873-
return;
76874-
}
76875-
for (const adv of adventures) {
76876-
let toValidate;
76877-
let skip2 = false;
76878-
if (type === "search") {
76879-
toValidate = (_a3 = adv.querySelector(".name")) == null ? void 0 : _a3.innerHTML;
76880-
} else if (type === "lang") {
76881-
toValidate = adv.getAttribute("data-lang");
76882-
} else {
76883-
const advTags = ((_b = adv.querySelector("#tags-list")) == null ? void 0 : _b.children) || [];
76884-
for (const t2 of advTags) {
76885-
const value = t2.innerHTML.trim();
76886-
if (term.includes(value)) {
76887-
if (filtered[type].exclude.some((a) => a === adv)) {
76888-
filtered[type].exclude = filtered[type].exclude.filter((a) => a !== adv);
76889-
}
76890-
skip2 = true;
76891-
break;
76892-
}
76849+
var levelSelect = document.getElementById("level-select");
76850+
var languageSelect = document.getElementById("language-select");
76851+
var tagsSelect = document.getElementById("tag-select");
76852+
var searchInput = document.getElementById("search_adventure");
76853+
var searchTimeout;
76854+
searchInput == null ? void 0 : searchInput.addEventListener("input", handleSearchInput);
76855+
document.addEventListener("DOMContentLoaded", () => {
76856+
const options = document.querySelectorAll(".option");
76857+
options.forEach(function(option2) {
76858+
option2.addEventListener("click", function() {
76859+
const dropdown = option2.closest(".dropdown");
76860+
if (!dropdown) {
76861+
return;
7689376862
}
76894-
}
76895-
if (skip2)
76896-
continue;
76897-
if (term && (toValidate == null ? void 0 : toValidate.includes(term))) {
76898-
if (filtered[type].exclude.some((a) => a === adv)) {
76899-
filtered[type].exclude = filtered[type].exclude.filter((a) => a !== adv);
76863+
const isSingleSelect = (dropdown == null ? void 0 : dropdown.getAttribute("data-type")) === "single";
76864+
if (isSingleSelect && !option2.classList.contains("selected")) {
76865+
const otherOptions = dropdown.querySelectorAll(".option.selected");
76866+
otherOptions.forEach((otherOption) => otherOption.classList.remove("selected"));
7690076867
}
76901-
} else if (term) {
76902-
if (filtered.term !== term && !filtered[type].exclude.some((a) => a === adv)) {
76903-
filtered[type].exclude.push(adv);
76868+
let nextValue = dropdown.getAttribute("data-value");
76869+
if (option2.classList.contains("selected")) {
76870+
nextValue = nextValue == null ? void 0 : nextValue.replace(option2.getAttribute("data-value"), "");
76871+
if (!isSingleSelect) {
76872+
nextValue = nextValue.split(",").filter((v) => v).join(",");
76873+
} else {
76874+
return;
76875+
}
76876+
} else if (!isSingleSelect) {
76877+
const currentValue = dropdown.getAttribute("data-value") || "";
76878+
nextValue = [currentValue, option2.getAttribute("data-value") || ""].filter((v) => v).join(",");
76879+
} else {
76880+
nextValue = option2.getAttribute("data-value") || "";
7690476881
}
76905-
}
76882+
dropdown.setAttribute("data-value", nextValue);
76883+
option2.classList.toggle("selected");
76884+
updateLabelText(dropdown);
76885+
updateDOM();
76886+
});
76887+
});
76888+
updateDOM();
76889+
setTimeout(() => {
76890+
if (!levelSelect)
76891+
return;
76892+
const level3 = levelSelect.getAttribute("data-value") || "";
76893+
const cloneBtn = document.getElementById(`clone_adventure_btn_${level3}`);
76894+
cloneBtn == null ? void 0 : cloneBtn.addEventListener("click", handleCloning);
76895+
}, 500);
76896+
});
76897+
function getSelectedOptions(_options) {
76898+
return Array.from(_options).filter((option2) => option2.classList.contains("selected")).map((option2) => {
76899+
var _a3;
76900+
return (_a3 = option2.textContent) == null ? void 0 : _a3.trim();
76901+
});
76902+
}
76903+
function updateLabelText(dropdown) {
76904+
const toggleButton = dropdown.querySelector(".toggle-button");
76905+
const relativeOptions = dropdown.querySelectorAll(".option");
76906+
const label = toggleButton.querySelector(".label");
76907+
const selectedOptions = getSelectedOptions(relativeOptions);
76908+
label.textContent = selectedOptions.length === 0 ? label.getAttribute("data-value") : selectedOptions.join(", ");
76909+
}
76910+
async function handleCloning(e) {
76911+
const target = e.target;
76912+
const adventureId = target.getAttribute("data-id");
76913+
try {
76914+
const data = await postJson(`public-adventures/clone/${adventureId}`);
76915+
modal.notifySuccess(data.message);
76916+
await updateDOM();
76917+
} catch (error2) {
76918+
modal.notifyError(error2.responseText);
7690676919
}
76907-
for (const adv of adventures) {
76908-
let allFiltersPassed = true;
76909-
for (const t2 in filtered) {
76910-
if (filtered[t2].exclude.some((a) => a === adv)) {
76911-
allFiltersPassed = false;
76912-
}
76913-
}
76914-
if (allFiltersPassed) {
76915-
adv.classList.remove("hidden");
76916-
} else {
76917-
adv.classList.add("hidden");
76920+
}
76921+
function handleSearchInput() {
76922+
clearTimeout(searchTimeout);
76923+
searchTimeout = setTimeout(updateDOM, 500);
76924+
}
76925+
function updateURL() {
76926+
const queryString = window.location.search;
76927+
const urlParams = new URLSearchParams(queryString);
76928+
const level3 = levelSelect.getAttribute("data-value") || "";
76929+
const lanugage = languageSelect.getAttribute("data-value") || "";
76930+
const tags2 = tagsSelect.getAttribute("data-value") || "";
76931+
urlParams.set("level", level3);
76932+
urlParams.set("lang", lanugage);
76933+
urlParams.set("tag", tags2);
76934+
if (searchInput) {
76935+
urlParams.set("search", searchInput.value);
76936+
}
76937+
window.history.pushState({}, "", `${window.location.pathname}?${urlParams.toString()}`);
76938+
}
76939+
async function updateDOM() {
76940+
if (!levelSelect || !languageSelect || !tagsSelect)
76941+
return;
76942+
const level3 = levelSelect.getAttribute("data-value") || "";
76943+
const lanugage = languageSelect.getAttribute("data-value") || "";
76944+
const tags2 = tagsSelect.getAttribute("data-value") || "";
76945+
const response = await fetch(`public-adventures/filter?tag=${tags2}&lang=${lanugage}&level=${level3}&search=${searchInput == null ? void 0 : searchInput.value}`, {
76946+
method: "GET",
76947+
keepalive: true,
76948+
headers: {
76949+
"Content-Type": "application/json; charset=utf-8",
76950+
"Accept": "application/json"
7691876951
}
76952+
});
76953+
const { html, js } = await response.json();
76954+
updateURL();
76955+
const publicAdventuresBody = document.getElementById("public-adventures-body");
76956+
if (publicAdventuresBody) {
76957+
publicAdventuresBody.innerHTML = html;
76958+
initialize({
76959+
lang: js.lang,
76960+
level: js.level,
76961+
keyword_language: js.lang,
76962+
javascriptPageOptions: js
76963+
});
76964+
initializeHighlightedCodeBlocks(publicAdventuresBody);
76965+
const cloneBtn = document.getElementById(`clone_adventure_btn_${level3}`);
76966+
cloneBtn == null ? void 0 : cloneBtn.addEventListener("click", handleCloning);
7691976967
}
7692076968
}
76921-
var tags2 = document.getElementById("tag");
76922-
var tagsInstance = _r.getInstance(tags2);
76923-
tags2 == null ? void 0 : tags2.addEventListener("valueChange.te.select", () => {
76924-
const value = tagsInstance.value.join(",");
76925-
applyFilter(value.replaceAll(",", " "), "tags", window.$filtered || {});
76926-
});
7692776969
return js_exports;
7692876970
})();
7692976971
/*!

static/js/appbundle.js.map

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)