1 2let windowObjectReference = null; // global variable 3const externalScriptPromises = {}; 4 5function loadExternalScriptOnce(src) { 6 if (externalScriptPromises[src]) { 7 return externalScriptPromises[src]; 8 } 9 10 externalScriptPromises[src] = new Promise((resolve, reject) => { 11 const existingScript = document.querySelector(`script[src="${src}"]`); 12 if (existingScript) { 13 if (existingScript.dataset.loaded === 'true') { 14 resolve(); 15 return; 16 } 17 18 existingScript.addEventListener('load', () => resolve(), { once: true }); 19 existingScript.addEventListener('error', () => reject(new Error(`Failed to load script: ${src}`)), { once: true }); 20 return; 21 } 22 23 const script = document.createElement('script'); 24 script.src = src; 25 script.async = true; 26 script.onload = () => { 27 script.dataset.loaded = 'true'; 28 resolve(); 29 }; 30 script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); 31 document.head.appendChild(script); 32 }); 33 34 return externalScriptPromises[src]; 35} 36 37function runWhenBrowserIdle(callback, timeout = 2000) { 38 if ('requestIdleCallback' in window) { 39 window.requestIdleCallback(callback, { timeout }); 40 return; 41 } 42 43 window.setTimeout(callback, 1500); 44} 45function lazyInitShareThis() { 46 if (!document.querySelector('.sharethis-inline-share-buttons')) { 47 return; 48 } 49 50 const loadShareThis = () => { 51 runWhenBrowserIdle(() => { 52 loadExternalScriptOnce('https://platform-api.sharethis.com/js/sharethis.js#property=685bb6a18ca9160019f294e2&product=sop') 53 .catch(error => console.error(error)); 54 }); 55 }; 56 57 if (document.readyState === 'complete') { 58 loadShareThis(); 59 return; 60 } 61 62 window.addEventListener('load', loadShareThis, { once: true }); 63} 64 65function openRequestedTab(href) { 66 if (windowObjectReference === null || windowObjectReference.closed) { 67 // const url = `${href}_${window.UIConfig.theme === 'dark' ? 'dark' : 'light'}.png`; 68 const url = `${href}?theme=${window.UIConfig.theme === 'dark' ? 'dark' : 'light'}`; 69 const popUpParams = `scrollbars=no,resizable=yes,status=no,location=no,toolbar=no,menubar=no,width=0,height=0,left=-1000,top=-1000`; 70 windowObjectReference = window.open(url, 'Sakila DB ER Diagram', popUpParams); 71 } else { 72 windowObjectReference.focus(); 73 } 74} 75 76function switchTheme(e) { 77 const currentTheme = e.target.checked ? 'dark' : 'light'; 78 if (window.sql_editor) { 79 window.sql_editor.setTheme(getAceTheme(currentTheme)); 80 } 81 document.documentElement.setAttribute('data-theme', currentTheme); 82 window.UIConfig.theme = currentTheme; 83 saveUIConfig(); 84} 85 86function getAceTheme(theme) { 87 return theme === 'dark' ? 'ace/theme/github_dark' : 'ace/theme/xcode'; 88} 89 90function formatCode() { 91 const beautify = ace.require("ace/ext/beautify"); 92 const editor_session = window.sql_editor.session; 93 beautify.beautify(editor_session); 94} 95function setLoader(id) { 96 return document.getElementById(id).innerHTML = '<div class="loader">Loading...</div>'; 97} 98 99function showToast(state, msg) { 100 const toast = document.getElementById("toast"); 101 if (state !== "error") { 102 toast.classList.remove("error"); 103 state = "info"; 104 } 105 toast.innerText = msg || "..."; 106 toast.classList.remove("info"); 107 toast.classList.add(state); 108 toast.classList.toggle("visible"); 109 const showTime = state === "error" ? 5000 : 2000; 110 setTimeout((function() { 111 toast.classList.toggle("visible"); 112 } 113 ), showTime) 114} 115function loadMenu(questionnire) { 116 fetch(`/${lang}/menu?questionnire=${questionnire}`, { 117 method: "GET", 118 mode: "cors", 119 cache: "default", 120 credentials: "same-origin", 121 }) 122 .then((async response=>{ 123 if (!response.ok) throw Error('Something went wrong.'); 124 return await response.text(); 125 })) 126 .then((message)=>{ 127 // The response is the whole <nav id="menu">: replace the element, don't nest it 128 const menu = document.getElementById('menu'); 129 menu.insertAdjacentHTML('afterend', message); 130 menu.remove(); 131 menuGroupsRequest = null; 132 window.UIConfig.questionnire = questionnire; 133 saveUIConfig(); 134 setMenuEventListeners(document.getElementById('menu')); 135 }) 136 .catch(err=>{ 137 console.log(err) 138 });; 139} 140function copyCode(msg) { 141 const editor = window.sql_editor; 142 navigator.clipboard && navigator.clipboard.writeText(editor.getValue()), 143 showToast('info', msg) 144} 145 146function clearEditor() { 147 const editor = window.sql_editor; 148 editor.setValue(''); 149 editor.focus(); 150 editor.session.selection.clearSelection(); 151} 152 153function clearFreeAnswer() { 154 stopVoiceInput(); 155 const textarea = document.getElementById('free-answer-input'); 156 if (!textarea) return; 157 textarea.value = ''; 158 textarea.focus(); 159} 160 161function toggleLoginWindow() { 162 const popup = document.getElementById('login-menu'); 163 popup.classList.toggle("hidden"); 164 // const loginWindow = document.getElementById("login-popup"); 165 // setTimeout((function() { 166 // loginWindow.classList.toggle("visible"); 167 // } 168 // ), 333) 169} 170 171function toggleMobileMenu() { 172 const dropdown = document.getElementById('mobileMenuDropdown'); 173 dropdown.classList.toggle("hidden"); 174} 175 176/** 177 * User menu in the top menu (#userMenuBtn, top-menu.tpl / m.top-menu.tpl): opens the profile and achievements popup. 178 * Clicks inside the popup don't close it; the button, Esc or a click outside do. 179 */ 180function toggleAchievements(lang) { 181 const popup = document.getElementById('achievements-popup'); 182 const button = document.getElementById('userMenuBtn'); 183 184 if (!popup.classList.contains('hidden')) { 185 closeAchievements(); 186 return; 187 } 188 popup.classList.remove('hidden'); 189 button?.setAttribute('aria-expanded', 'true'); 190 document.addEventListener('click', closeAchievementsOutside, true); 191 document.addEventListener('keydown', closeAchievementsOnEscape); 192
193 setLoader('achievements-popup'); 194 // Load achievements when opening popup 195 fetch(`/${lang}/user/achievements`) 196 .then((async response=>{ 197 if (!response.ok) throw Error('Something went wrong.'); 198 return await response.text(); 199 })) 200 .then((message)=>{ 201 popup.innerHTML = message; 202 }) 203 .catch(error => { 204 popup.innerHTML = '<p>Something went wrong.</p>'; 205 }); 206} 207 208function closeAchievements() { 209 document.getElementById('achievements-popup')?.classList.add('hidden'); 210 document.getElementById('userMenuBtn')?.setAttribute('aria-expanded', 'false'); 211 document.removeEventListener('click', closeAchievementsOutside, true); 212 document.removeEventListener('keydown', closeAchievementsOnEscape); 213} 214 215function closeAchievementsOutside(event) { 216 const popup = document.getElementById('achievements-popup'); 217 const button = document.getElementById('userMenuBtn'); 218 if (!popup.contains(event.target) && !button?.contains(event.target)) { 219 closeAchievements(); 220 } 221} 222 223function closeAchievementsOnEscape(event) { 224 if (event.key === 'Escape') { 225 closeAchievements(); 226 document.getElementById('userMenuBtn')?.focus(); 227 } 228} 229function jsonToTable(jsonObject) { 230 let htmlTable = ''; 231 let rn = 0; 232 try { 233 htmlTable = "<table class='result-table'><tr><th></th><th>" + jsonObject.headers.map(h=>h.header).join('</th><th>') + "</th></tr>"; 234 for (let r of jsonObject.data) { 235 htmlTable += "<tr><td>" + (++rn) +"</td><td>" + r.map(el=>el === null ? '[null]' : el).join('</td><td>') + "</td></tr>"; 236 } 237 htmlTable += "</table>"; 238 } catch(e) { 239 htmlTable = 'Something went wrong. Please review your query and try again.' 240 } 241 return htmlTable; 242} 243 244function errorToTable(jsonObject) { 245 const error = document.createElement('span'); 246 error.className = 'sql_error'; 247 error.textContent = jsonObject.error; 248 return error.outerHTML; 249} 250 251// SQL errors under the editor (runQuery, testQuery, the playground's executeQuery): where the error is, marked 252// in the message and in the editor, and the "Explain the error" AI button: on task pages it calls 253// Controller::explain_error (data-explain-url), in the playground it asks the playground AI chat 254// (data-explain-question). Both are paid from the AI tokens. 255// Settings and labels come from the data-* attributes of #code-result (index.tpl, m.index.tpl, playground templates). 256 257/** 258 * Where an engine's error message points in the query: {row, column, length} (0-based), or null. 259 * Formats: PostgreSQL/DuckDB "LINE 2: ... ^", Firebird "line 2, column 10", MySQL "near '...' at line 2", 260 * otherwise the first quoted name ('foo', "foo", no such table: foo) searched in the query. 261 */ 262function sqlErrorLocation(error, sql) { 263 const lines = sql.replace(/\r\n?/g, '\n').split('\n'); 264 const wordAt = (row, column) => { 265 const match = (lines[row] || '').slice(column).match(/^[\w$.]+|^\S/); 266 return match ? match[0].length : 1; 267 }; 268 const at = (row, column) => (row >= 0 && row < lines.length && column >= 0 && column <= lines[row].length) 269 ? {row, column, length: wordAt(row, column)} 270 : null; 271 const find = (text, row) => { 272 if (!text) return null; 273 const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 274 const pattern = new RegExp((/^\w/.test(text) ? '(^|[^\\w$])' : '()') + '(' + escaped + ')' + (/\w$/.test(text) ? '(?![\\w$])' : ''), 'i'); 275 const rows = row === undefined ? lines.map((_, i) => i) : [row]; 276 for (const r of rows) { 277 const match = (lines[r] || '').match(pattern); 278 if (match) return {row: r, column: match.index + match[1].length, length: text.length}; 279 } 280 return null; 281 }; 282 283 let match = error.match(/LINE (\d+): ([^\n]*)\n( *)\^/); 284 if (match) { 285 const row = parseInt(match[1]) - 1; 286 let column = match[3].length - ('LINE ' + match[1] + ': ').length; 287 // A long line is shown cut: "...fragment..."; find the fragment in the line 288 if (match[2].startsWith('...')) { 289 const fragment = match[2].slice(3).replace(/\.\.\.$/, ''); 290 const offset = (lines[row] || '').indexOf(fragment); 291 column = offset < 0 ? -1 : offset + column - 3; 292 } 293 const location = at(row, column); 294 if (location) return location; 295 } 296 match = error.match(/line (\d+), column (\d+)/i); 297 if (match) { 298 const location = at(parseInt(match[1]) - 1, parseInt(match[2]) - 1); 299 if (location) return location; 300 } 301 match = error.match(/near '([\s\S]*)' at line (\d+)/); 302 if (match) {
303 const row = parseInt(match[2]) - 1; 304 const fragment = match[1].split('\n')[0].trim(); 305 if (fragment === '') { 306 // Unexpected end of the query: mark its last word 307 const lastRow = Math.min(row, lines.length - 1); 308 const lastWord = lastRow >= 0 ? lines[lastRow].match(/[\w$.]+\s*$|\S\s*$/) : null; 309 return lastWord ? at(lastRow, lastWord.index) : null; 310 } 311 const location = find(fragment, row) || find(fragment); 312 if (location) return {...location, length: wordAt(location.row, location.column)}; 313 } 314 match = error.match(/['"`]([^'"`\n]{1,64})['"`]|no such (?:table|column): ([\w$.]+)/); 315 if (match) { 316 const name = match[1] || match[2]; 317 return find(name) || find(name.split('.').pop()); 318 } 319 return null; 320} 321 322function clearSqlErrorMarker() { 323 const marker = window.sqlErrorMarker; 324 if (marker && window.sql_editor) { 325 window.sql_editor.session.removeMarker(marker.id); 326 window.sql_editor.session.removeGutterDecoration(marker.row, 'sql-error-gutter'); 327 } 328 window.sqlErrorMarker = null; 329} 330 331function markSqlErrorInEditor(location) { 332 clearSqlErrorMarker(); 333 if (!window.sql_editor) return; 334 const Range = ace.require('ace/range').Range; 335 const session = window.sql_editor.session; 336 window.sqlErrorMarker = { 337 id: session.addMarker(new Range(location.row, location.column, location.row, location.column + location.length), 'sql-error-marker', 'text'), 338 row: location.row 339 }; 340 session.addGutterDecoration(location.row, 'sql-error-gutter'); 341 // Remove the mark once the query is edited 342 session.once('change', clearSqlErrorMarker); 343} 344 345/** 346 * Adds the location and the AI button to each .sql_error in container 347 * 348 * @param {HTMLElement} container #code-result 349 * @param {string} sql The query that produced the result 350 */ 351function enhanceSqlErrors(container, sql) { 352 const settings = document.getElementById('code-result')?.dataset || {}; 353 container.querySelectorAll('.sql_error').forEach(errorElement => { 354 const error = errorElement.textContent; 355 const location = sqlErrorLocation(error, sql); 356 const tools = document.createElement('div'); 357 tools.className = 'sql-error-tools'; 358 359 if (location) { 360 markSqlErrorInEditor(location); 361 const goTo = document.createElement('button'); 362 goTo.type = 'button'; 363 goTo.className = 'text-button blue sql-error-goto'; 364 goTo.title = settings.gotoLabel || ''; 365 goTo.textContent = (settings.locationLabel || 'Line {line}, column {column}') 366 .replace('{line}', location.row + 1) 367 .replace('{column}', location.column + 1); 368 goTo.addEventListener('click', () => { 369 if (!window.sql_editor) return; 370 window.sql_editor.selection.setRange(new (ace.require('ace/range').Range)(location.row, location.column, location.row, location.column + location.length)); 371 window.sql_editor.scrollToLine(location.row, true, true); 372 window.sql_editor.focus(); 373 }); 374 tools.appendChild(goTo); 375 } 376 377 const chatMode = settings.explainQuestion && typeof window.aiAssistantAsk === 'function'; 378 if (!chatMode && !settings.explainUrl) { 379 errorElement.after(tools); 380 return; 381 } 382 const explain = sqlErrorExplainButton(settings); 383 tools.appendChild(explain); 384 const answer = document.createElement('div'); 385 answer.className = 'sql-error-explanation hidden'; 386 errorElement.after(tools, answer); 387 388 explain.addEventListener('click', () => { 389 if (settings.logged !== '1') {
390 showSqlErrorLoginPrompt(settings, answer, sql); 391 } else if (chatMode) { 392 // Playground: the question goes to its AI chat panel (js/ai-assistant.js), which sees the error itself 393 window.aiAssistantAsk(settings.explainQuestion); 394 } else { 395 explainSqlError(settings, explain, answer, sql, error); 396 } 397 }); 398 399 // Back from the login started by the guest prompt: point at the button 400 if (window.sqlErrorExplainResumed) { 401 window.sqlErrorExplainResumed = false; 402 explain.scrollIntoView({block: 'center', behavior: 'smooth'}); 403 explain.classList.add('ai-attention'); 404 } 405 }); 406} 407 408// Guests: say why the AI needs an account, right under the error, with a button that opens the login menu. 409// The query is kept in sessionStorage, so after the login (the page reloads) it is run again. 410const SQL_ERROR_EXPLAIN_PENDING = 'sqlErrorExplainPending'; 411 412function showSqlErrorLoginPrompt(settings, container, sql) { 413 container.classList.remove('hidden'); 414 if (container.querySelector('.sql-error-login')) return; 415 const prompt = document.createElement('div'); 416 prompt.className = 'sql-error-login'; 417 const text = document.createElement('p'); 418 text.textContent = settings.loginText; 419 const button = document.createElement('button'); 420 button.type = 'button'; 421 button.className = 'button green'; 422 button.textContent = settings.loginButton; 423 button.addEventListener('click', event => { 424 // Keep the document click handlers from closing the menu right away 425 event.stopPropagation(); 426 try { 427 sessionStorage.setItem(SQL_ERROR_EXPLAIN_PENDING, JSON.stringify({path: location.pathname, sql})); 428 } catch (e) {} 429 openLoginMenu(); 430 }); 431 prompt.append(text, button); 432 container.appendChild(prompt); 433} 434 435function openLoginMenu() { 436 const menu = document.getElementById('login-menu'); 437 if (!menu) return; 438 menu.classList.remove('hidden'); 439 (menu.closest('header') || menu).scrollIntoView({block: 'start', behavior: 'smooth'}); 440 // Restart the highlight animation 441 menu.classList.remove('ai-attention'); 442 void menu.offsetWidth; 443 menu.classList.add('ai-attention'); 444} 445 446// After the login: put the query back and run it, so the error and the explain button show up again 447function resumeSqlErrorExplain() { 448 let pending = null; 449 try { 450 pending = JSON.parse(sessionStorage.getItem(SQL_ERROR_EXPLAIN_PENDING) || 'null'); 451 } catch (e) {} 452 const settings = document.getElementById('code-result')?.dataset; 453 if (!pending || !settings || settings.logged !== '1' || !window.sql_editor) return; 454 try { 455 sessionStorage.removeItem(SQL_ERROR_EXPLAIN_PENDING); 456 } catch (e) {} 457 if (pending.path !== location.pathname || !pending.sql) return; 458 459 window.sql_editor.setValue(pending.sql, 1); 460 window.sqlErrorExplainResumed = true; 461 if (typeof executeQuery === 'function') { 462 executeQuery(); 463 } else if (questionId && questionId !== 'null') { 464 runQuery(lang, questionId); 465 } 466} 467// After the playground's own editor setup on DOMContentLoaded (shared snippets) 468window.addEventListener('load', () => setTimeout(resumeSqlErrorExplain, 300)); 469 470function sqlErrorExplainButton(settings) { 471 const button = document.createElement('button'); 472 button.type = 'button'; 473 button.className = 'text-button blue sql-error-explain'; 474 button.title = settings.explainHint || ''; 475 button.textContent = '⨠' + settings.explainLabel; 476 return button; 477} 478 479function explainSqlError(settings, button, answer, sql, error) { 480 if (button.disabled) return; 481 const label = button.textContent; 482 button.disabled = true; 483 button.textContent = settings.explainLoading; 484 let formData = new FormData(); 485 formData.append('sql', sql); 486 formData.append('error', error); 487 fetch(settings.explainUrl, { 488 method: "POST", 489 credentials: "same-origin", 490 body: formData, 491 }) 492 .then(response => response.json()) 493 .then(data => { 494 answer.classList.remove('hidden'); 495 if (data.answer_html) { 496 answer.innerHTML = data.answer_html; 497 if (data.quota && settings.tokensLeftLabel) { 498 const quota = document.createElement('div'); 499 quota.className = 'sql-error-quota'; 500 quota.textContent = settings.tokensLeftLabel + ' ' + data.quota.remaining_text; 501 answer.appendChild(quota); 502 } 503 button.remove(); 504 return; 505 } 506 // Server messages are site translations, some with links (buy tokens, log in) 507 answer.innerHTML = data.message || 'Something went wrong.'; 508 button.disabled = false;
509 button.textContent = label; 510 }) 511 .catch(() => { 512 answer.classList.remove('hidden'); 513 answer.textContent = 'Something went wrong.'; 514 button.disabled = false; 515 button.textContent = label; 516 }); 517} 518 519/** 520 * "Get hint": the hint goes to #hint-panel (hint_panel.tpl) above the editor, so the query result stays. 521 * The button toggles the panel; the hint is loaded once. Pages without the panel show it in #code-result. 522 */ 523function getHelp(lang, questionId) { 524 const panel = document.getElementById('hint-panel'); 525 const content = document.getElementById('hint-panel-content'); 526 if (panel && !panel.classList.contains('hidden')) { 527 closeHint(); 528 return; 529 } 530 if (panel && content.dataset.loaded) { 531 showHint(panel); 532 return; 533 } 534 const target = panel ? content : document.getElementById('code-result'); 535 if (panel) { 536 showHint(panel); 537 } 538 setLoader(target.id); 539 fetch(`/${lang}/question/${questionId}/query-help`, { 540 method: "GET", 541 mode: "cors", 542 cache: "default", 543 credentials: "same-origin", 544 }) 545 .then((async response=>{ 546 if (!response.ok) throw Error('Something went wrong.'); 547 return await response.text(); 548 })) 549 .then((message)=>{ 550 target.innerHTML = message; 551 if (panel) { 552 content.dataset.loaded = '1'; 553 } 554 }) 555 .catch(()=>{ 556 target.textContent = 'Something went wrong. Please try again.'; 557 }); 558} 559 560function showHint(panel) { 561 panel.classList.remove('hidden'); 562 document.getElementById('getHelpBtn')?.setAttribute('aria-expanded', 'true'); 563 panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 564} 565 566function closeHint() { 567 document.getElementById('hint-panel')?.classList.add('hidden'); 568 document.getElementById('getHelpBtn')?.setAttribute('aria-expanded', 'false'); 569} 570 571function runQuery(lang, questionId) { 572 setLoader('code-result'); 573 clearSqlErrorMarker(); 574 const sql = window.sql_editor.getValue(); 575 let formData = new FormData(); 576 formData.append('query', sql); 577 fetch(`/${lang}/question/${questionId}/query-run`, { 578 method: "POST", 579 mode: "cors", 580 cache: "default", 581 credentials: "same-origin", 582 body: formData, 583 }) 584 .then((async response=>{ 585 if (!response.ok) throw Error('Something went wrong.'); 586 return await response.text(); 587 })) 588 .then(JSON.parse) 589 .then((JSONmessage)=>{ 590 let html = 'â (Done)'; 591 if (JSONmessage && JSONmessage[0]) { 592 const jsonObject = JSONmessage[0]; 593 html = jsonObject.error 594 ? errorToTable(jsonObject) 595 : jsonToTable(jsonObject); 596 } 597 document.getElementById('code-result').innerHTML = html; 598 enhanceSqlErrors(document.getElementById('code-result'), sql); 599 }) 600 .catch(err=>{ 601 document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again or contact us by email: <a href="mailto:[email protected]">[email protected]</a>.'; 602 }); 603} 604function checkAnswers(lang, questionId) { 605 setLoader('code-result'); 606 const answers = [...document.querySelectorAll('input[name=answers]:checked')] 607 .reduce( 608 (res, el)=>{res.push(parseInt(el.value)); return res;}, 609 [] 610 ) 611 .toSorted(); 612 613 let formData = new FormData(); 614 formData.append('answers', JSON.stringify(answers)); 615 fetch(`/${lang}/question/${questionId}/check-answers`, { 616 method: "POST", 617 mode: "cors", 618 cache: "default", 619 credentials: "same-origin", 620 body: formData, 621 }) 622 .then((async response=>{ 623 if (response.ok) { 624 if (response.ok) { 625 showSolvedProgress(response); 626 } 627 if (response.ok && document.getElementById("nextTaskBtn")) { 628 document.getElementById("nextTaskBtn").classList.remove("hidden"); 629 setTimeout(()=>{ 630 document.getElementById("main3").scrollTo({ 631 top: document.getElementById("nextTaskBtn").offsetTop, 632 behavior: "smooth" 633 }) 634 }, 300) 635 } 636 } 637 return await response.text(); 638 })) 639 .then((message)=>{ 640 document.getElementById('code-result').innerHTML = message; 641 placeNewAchievement(document.getElementById('code-result')); 642 }) 643 .catch(err=>{ 644 document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again or c
644ontact us by email: <a href="mailto:[email protected]">[email protected]</a>.'; 645 }); 646} 647function checkFreeAnswer(lang, questionId) { 648 // One check at a time: each check is paid from the AI token balance 649 const checkBtn = document.getElementById('checkFreeAnswerBtn'); 650 if (checkBtn?.disabled) { 651 return; 652 } 653 if (checkBtn) { 654 checkBtn.disabled = true; 655 } 656 setLoader('code-result'); 657 let formData = new FormData(); 658 formData.append('answer', document.getElementById('free-answer-input').value); 659 fetch(`/${lang}/question/${questionId}/check-free-answer`, { 660 method: "POST", 661 mode: "cors", 662 cache: "default", 663 credentials: "same-origin", 664 body: formData, 665 }) 666 .then((async response=>{ 667 // The check is paid from the AI token balance: refresh the counter and the buy button 668 const remaining = response.headers.get('X-AI-Tokens-Remaining'); 669 if (remaining !== null && document.getElementById('free-answer-tokens-remaining')) { 670 document.getElementById('free-answer-tokens-remaining').textContent = decodeURIComponent(remaining); 671 document.getElementById('buyTokensBtn')?.classList.toggle('hidden', response.headers.get('X-AI-Tokens-Low') !== '1'); 672 } 673 if (response.ok) { 674 showSolvedProgress(response); 675 } 676 if (response.ok && document.getElementById("nextTaskBtn")) { 677 document.getElementById("nextTaskBtn").classList.remove("hidden"); 678 setTimeout(()=>{ 679 if (document.getElementById("main3")) { 680 document.getElementById("main3").scrollTo({ 681 top: document.getElementById("nextTaskBtn").offsetTop, 682 behavior: "smooth" 683 }) 684 } else { 685 window.scrollTo({ 686 top: document.getElementById("db-description").offsetTop - window.outerHeight, 687 behavior: "smooth" 688 }) 689 } 690 }, 300) 691 } 692 return await response.text(); 693 })) 694 .then((message)=>{ 695 document.getElementById('code-result').innerHTML = message; 696 placeNewAchievement(document.getElementById('code-result')); 697 }) 698 .catch(err=>{ 699 document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your answer and try again or contact us by email: <a href="mailto:[email protected]">[email protected]</a>.'; 700 }) 701 .finally(()=>{ 702 if (checkBtn) { 703 checkBtn.disabled = false; 704 } 705 }); 706} 707// Maps site interface language codes to BCP-47 locale tags the Web Speech API expects. 708const VOICE_INPUT_LANG_MAP = { 709 en: 'en-US', 710 ru: 'ru-RU', 711 pt: 'pt-BR', 712 fr: 'fr-FR', 713 zh: 'zh-CN', 714}; 715 716let voiceRecognition = null; 717let voiceInputActive = false; 718 719function getSpeechRecognitionClass() { 720 return window.SpeechRecognition || window.webkitSpeechRecognition || null; 721} 722 723function toggleVoiceInput(lang) { 724 if (voiceInputActive) { 725 stopVoiceInput(); 726 return; 727 } 728 const SpeechRecognitionClass = getSpeechRecognitionClass(); 729 if (!SpeechRecognitionClass) { 730 showToast('error', 'Voice input is not supported in this browser.'); 731 return; 732 } 733 startVoiceInput(SpeechRecognitionClass, lang); 734} 735 736function startVoiceInput(SpeechRecognitionClass, lang) { 737 const textarea = document.getElementById('free-answer-input'); 738 const btn = document.getElementById('voiceInputBtn'); 739 if (!textarea) return; 740 741 voiceRecognition = new SpeechRecognitionClass(); 742 voiceRecognition.lang = VOICE_INPUT_LANG_MAP[lang] || 'en-US'; 743 // continuous:true is unreliable on mobile Chrome â it can re-emit already-finalized 744 // results, duplicating whatever was just spoken. Instead we recognize one utterance 745 // at a time and auto-restart in onend, which behaves consistently on desktop and mobile. 746 voiceRecognition.continuous = false; 747 voiceRecognition.interimResults = true; 748 749 // Recognized speech is appended after whatever was already in the textarea 750 // (typed text or a previously saved answer) rather than overwriting it. Each 751 // finished utterance gets folded into this once it's final, before the next 752 // utterance's (fresh, zero-indexed) results start arriving. 753 let baseValue = textarea.value; 754 755 voiceRecognition.onresult = function(event) { 756 let transcript = ''; 757 for (let i = 0; i < event.results.length; i++) { 758 transcript += event.results[i][0].transcript; 759 } 760 textarea.value = (baseValue + ' ' + transcript).trim(); 761 if (event.results[event.results.length - 1].isFinal) { 762 baseValue = textarea.value; 763 } 764 }; 765 766 voiceRecognition.onerror = function(event) { 767 if (event.error === 'no-speech' || event.error === 'aborted') { 768 // Not fatal â onend fires right after and restarts listening if still active. 769 return; 770 } 771 showToast('error', 'Voice input error: ' + event.error); 772 stopVoiceInput(); 773 }; 774 775 voiceRecognition.onend = function() { 776 // Each utterance ends the recognizer on its own (continuous is off), so keep 777 // dictation going by restarting â unless the user already clicked stop. 778 if (voiceInputActive) { 779 try { 780 voiceRecognition.start(); 781 } catch (e) { 782 stopVoiceInput(); 783 } 784 } 785 }; 786 787 voiceInputActive = true; 788 if (btn) btn.classList.add('listening'); 789 voiceRecognition.start(); 790} 791 792function stopVoiceInput() { 793 voiceInputActive = false;
794 const btn = document.getElementById('voiceInputBtn'); 795 if (btn) btn.classList.remove('listening'); 796 if (voiceRecognition) { 797 const recognitionToStop = voiceRecognition; 798 voiceRecognition = null; 799 recognitionToStop.onend = null; 800 recognitionToStop.onresult = null; 801 recognitionToStop.onerror = null; 802 recognitionToStop.stop(); 803 } 804} 805 806/** 807 * New-achievement line (new_achievement.tpl): opening a link in it (the achievement page or a share link) or closing 808 * it (Ã or Esc) marks the achievement viewed, so it isn't shown again. 809 */ 810function initNewAchievement(block) { 811 const viewUrl = block.dataset.achievementViewUrl; 812 let isMarkedViewed = false; 813 814 const markViewed = function () { 815 if (isMarkedViewed || !viewUrl) { 816 return; 817 } 818 isMarkedViewed = true; 819 fetch(viewUrl, { method: 'GET', credentials: 'same-origin', keepalive: true }) 820 .catch(function () { 821 // Best effort: at worst the achievement is shown once more 822 }); 823 }; 824 const closeOnEscape = function (event) { 825 if (event.key === 'Escape') { 826 close(); 827 } 828 }; 829 const close = function () { 830 markViewed(); 831 block.remove(); 832 document.removeEventListener('keydown', closeOnEscape); 833 }; 834 835 block.addEventListener('click', function (event) { 836 if (event.target.closest('a')) { 837 markViewed(); 838 return; 839 } 840 if (event.target.closest('.new-achievement__close')) { 841 close(); 842 } 843 }); 844 document.addEventListener('keydown', closeOnEscape); 845} 846 847/** 848 * A correct check response may start with a new-achievement line (Controller::sendSolvedProgress()): move it above 849 * the task, replacing the one shown on page load. The mobile page has no main column: it stays in the result there. 850 */ 851function placeNewAchievement(container) { 852 const block = container.querySelector('.new-achievement'); 853 if (!block) { 854 return; 855 } 856 const mainColumn = document.querySelector('main.column'); 857 if (mainColumn) { 858 document.querySelectorAll('.new-achievement').forEach(el => el !== block && el.remove()); 859 mainColumn.prepend(block); 860 } 861 initNewAchievement(block); 862} 863 864/** 865 * After a correct check: mark the task solved in the menu and refresh "My progress" (my_progress.tpl) 866 * from the X-Solved-Count / X-Questions-Count headers (Controller::sendSolvedProgress()). 867 */ 868function showSolvedProgress(response) { 869 document.querySelectorAll('.question-link.current-question:not(.solved)').forEach(el => { 870 el.classList.add('solved'); 871 // "12 / 40" in the header of the task's menu group 872 const count = el.closest('.panel')?.previousElementSibling?.querySelector('.accordion-count'); 873 if (count) { 874 count.dataset.solved = parseInt(count.dataset.solved || '0', 10) + 1; 875 count.textContent = `${count.dataset.solved}\u2009/\u2009${count.dataset.total}`; 876 } 877 }); 878 879 const solved = parseInt(response.headers.get('X-Solved-Count'), 10); 880 const total = parseInt(response.headers.get('X-Questions-Count'), 10); 881 if (isNaN(solved) || !total) { 882 return; 883 } 884 const percent = solved / total * 100; 885 document.querySelectorAll('.progress-bar').forEach(el => el.style.width = `${percent}%`); 886 document.querySelectorAll('.progress-count').forEach(el => el.textContent = `${solved}/${total}`); 887 document.querySelectorAll('.progress-percentage').forEach(el => el.textContent = `${Math.round(percent * 10) / 10}%`); 888} 889 890function testQuery(lang, questionId) { 891 setLoader('code-result'); 892 clearSqlErrorMarker(); 893 const sql = window.sql_editor.getValue(); 894 let formData = new FormData(); 895 formData.append('query', sql); 896 fetch(`/${lang}/question/${questionId}/query-test`, { 897 method: "POST", 898 mode: "cors", 899 cache: "default", 900 credentials: "same-origin", 901 body: formData, 902 }) 903 .then((async response=>{ 904 if (response.ok) { 905 showSolvedProgress(response); 906 } 907 if (response.headers.get('X-Sample-Rows') === 'available') { 908 unlockSampleRows(); 909 } 910 if (response.ok && document.getElementById("nextTaskBtn")) { 911 document.getElementById("nextTaskBtn").classList.remove("hidden"); 912 setTimeout(()=>{ 913 if (document.getElementById("main3")) { 914 document.getElementById("main3").scrollTo({ 915 top: document.getElementById("nextTaskBtn").offsetTop, 916 behavior: "smooth" 917 }) 918 } else { 919 window.scrollTo({ 920 top: document.getElementById("db-description").offsetTop - window.outerHeight, 921 behavior: "smooth" 922 }) 923 } 924 }, 300) 925 } 926 return await response.text(); 927 })) 928 .then((message)=>{ 929 document.getElementById('code-result').innerHTML = message; 930 placeNewAchievement(document.getElementById('code-result')); 931 enhanceSqlErrors(document.getElementById('code-result'), sql); 932 }) 933 .catch(err=>{ 934 document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again or c
934ontact us by email: <a href="mailto:[email protected]">[email protected]</a>.'; 935 }); 936} 937// Expected result block (expected_result.tpl): the sample rows became available after a wrong check 938function unlockSampleRows() { 939 const block = document.getElementById('expected-result'); 940 if (!block) { 941 return; 942 } 943 const locked = block.querySelector('[data-sample-state="locked"]'); 944 if (locked) { 945 locked.remove(); 946 block.querySelector('[data-sample-state="available"]')?.classList.remove('hidden'); 947 } 948} 949// Load the first rows of the expected result into its block and open it 950function showSampleRows(lang, questionId) { 951 const block = document.getElementById('expected-result'); 952 if (!block) { 953 return; 954 } 955 block.open = true; 956 const reveal = () => block.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 957 if (block.dataset.sampleLoaded) { 958 reveal(); 959 return; 960 } 961 fetch(`/${lang}/question/${questionId}/expected-sample`, { credentials: 'same-origin' }) 962 .then(response => response.ok ? response.text() : Promise.reject(response.status)) 963 .then(html => { 964 document.getElementById('expected-result-rows').innerHTML = html; 965 block.dataset.sampleLoaded = '1'; 966 block.querySelector('[data-sample-state="available"]')?.remove(); 967 block.querySelector('[data-sample-state="shown"]')?.classList.remove('hidden'); 968 reveal(); 969 }) 970 .catch(err => console.error(err)); 971} 972function toggleFavorites(lang, questionId) { 973 let formData = new FormData(); 974 fetch(`/${lang}/question/${questionId}/favorite`, { 975 method: "POST", 976 mode: "cors", 977 cache: "default", 978 credentials: "same-origin", 979 body: formData, 980 }) 981 .then((async response=>{ 982 if (response.ok) { 983 if (document.getElementById("favoriteStar")) { 984 const message = await response.text(); 985 showToast('info', message); 986 const favored = document.getElementById("favoriteStar").classList.toggle("favored"); 987 document.getElementById("favoriteStar").setAttribute("aria-pressed", favored ? "true" : "false"); 988 // document.getElementById("favoriteStar").title = 'Favored' 989 } 990 } 991 })) 992 .catch(err=>{ 993 showToast('error', 'Something went wrong. Please ask admin for help.'); 994 }); 995} 996function moveQuestionPosition(questionId, categoryId, direction) { 997 fetch(`/admin/question-category-position`, { 998 method: "POST", 999 mode: "cors", 1000 cache: "default", 1001 credentials: "same-origin", 1002 headers: { 'Content-Type': 'application/json' }, 1003 body: JSON.stringify({ 1004 question_id: questionId, 1005 category_id: categoryId, 1006 direction: direction 1007 }), 1008 }) 1009 .then((async response=>{ 1010 const data = await response.json(); 1011 if (response.ok && data.ok) { 1012 loadMenu(window.UIConfig.questionnire); 1013 } else { 1014 showToast('error', data.error || 'Something went wrong.'); 1015 } 1016 })) 1017 .catch(err=>{ 1018 showToast('error', 'Something went wrong. Please ask admin for help.'); 1019 }); 1020} 1021function checkSolution(url) { 1022 setLoader('code-result'); 1023 let formData = new FormData(); 1024 if (window.sql_editor) { 1025 formData.append('query', window.sql_editor.getValue()); 1026 } 1027 if (document.getElementById('answers-list')) { 1028 const answers = [...document.querySelectorAll('input[name=answers]:checked')] 1029 .reduce( 1030 (res, el)=>{res.push(parseInt(el.value)); return res;}, 1031 [] 1032 ) 1033 .toSorted(); 1034 formData.append('answers', JSON.stringify(answers)); 1035 } 1036 if (document.getElementById('free-answer-input')) { 1037 const freeAnswer = document.getElementById('free-answer-input').value; 1038 formData.append('free-answer', freeAnswer); 1039 console.log('Free answer:', freeAnswer); 1040 } 1041 fetch(url, { 1042 method: "POST", 1043 mode: "cors", 1044 cache: "default", 1045 credentials: "same-origin", 1046 body: formData, 1047 }) 1048 .then((async response=>{ 1049 if (response.ok) { 1050 document.getElementById("checkSolutionBtn") && document.getElementById("checkSolutionBtn").classList.toggle("hidden"); 1051 document.getElementById("nextQuestionBtn") && document.getElementById("nextQuestionBtn").classList.toggle("hidden"); 1052 } else { 1053 // decrease attempts counter 1054 let attempts = document.getElementById('attemptsCount').innerText; 1055 if (parseInt(attempts) > 0) { 1056 document.getElementById('attemptsCount').innerText = (attempts - 1).toString(); 1057 } 1058 } 1059 return await response.text(); 1060 })) 1061 .then((message)=>{ 1062 document.getElementById('code-result').innerHTML = message; 1063 }) 1064 .catch(err=>{ 1065 document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again.'; 1066 }); 1067} 1068function showMySolutions(questionId) { 1069 showSolutions(questionId, 'my'); 1070} 1071function showOthersSolutions(questionId) { 1072 showSolutions(questionId, 'others'); 1073} 1074function showSolutions(questionId, whom) { 1075 document.getElementById('right-panel').innerHTML = '<div id="pre-loader" style="width: 21vw;"></div>';
1076 setLoader('pre-loader'); 1077 const url = whom === 'my' ? `/${lang}/question/${questionId}/my-solutions` : `/${lang}/question/${questionId}/solutions`; 1078 fetch(url, { 1079 method: "GET", 1080 mode: "cors", 1081 cache: "default", 1082 credentials: "same-origin", 1083 }) 1084 .then((async response=>{ 1085 if (!response.ok) throw Error('Something went wrong.'); 1086 return await response.text(); 1087 })) 1088 .then((message)=>{ 1089 document.getElementById('right-panel').innerHTML = message; 1090 }) 1091 .then(()=>{ 1092 [...document.getElementsByClassName("solution-block")].map(el=>{ 1093 ace 1094 .edit(el.id, { 1095 mode: "ace/mode/mysql", 1096 theme: getAceTheme(window.UIConfig.theme), 1097 dragEnabled: false, 1098 useWorker: false, 1099 readOnly: true 1100 }); 1101 }); 1102 }) 1103 .catch(err=>{ 1104 document.getElementById('right-panel').innerHTML = 'Something went wrong.'; 1105 }); 1106} 1107function solutionUpdate(solutionId, action) { 1108 return fetch(`/${lang}/solution/${solutionId}/${action}`, { 1109 method: "POST", 1110 mode: "cors", 1111 cache: "default", 1112 credentials: "same-origin", 1113 }) 1114 .then((async response=>{ 1115 if (response.ok) { 1116 const message = await response.text(); 1117 showToast('info', message); 1118 } 1119 })) 1120 .catch(err=>{ 1121 showToast('error', 'Something went wrong. Please ask admin for help.'); 1122 }); 1123} 1124function solutionLike(solutionId) { 1125 return solutionUpdate(solutionId, 'like') 1126 .then(()=>{ 1127 [...document.getElementById(`solution-likes-${solutionId}`).children].map(el=>el.classList.toggle("hidden")); 1128 document.getElementById(`solution-likes-count-${solutionId}`).innerText = parseInt(document.getElementById(`solution-likes-count-${solutionId}`).innerText) + 1; 1129 }); 1130} 1131function solutionUnlike(solutionId) { 1132 return solutionUpdate(solutionId, 'unlike') 1133 .then(()=>{ 1134 [...document.getElementById(`solution-likes-${solutionId}`).children].map(el=>el.classList.toggle("hidden")); 1135 document.getElementById(`solution-likes-count-${solutionId}`).innerText = parseInt(document.getElementById(`solution-likes-count-${solutionId}`).innerText) - 1; 1136 }); 1137} 1138// Cookie helpers and welcome-page handlers 1139function setCookie(name, value, days) { 1140 let expires = ''; 1141 if (days) { 1142 const date = new Date(); 1143 date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 1144 expires = '; expires=' + date.toUTCString(); 1145 } 1146 document.cookie = name + '=' + (value || '') + expires + '; path=/'; 1147} 1148function hideWelcome(hide) { 1149 if (hide) { 1150 setCookie('HideWelcome', '1', 365); 1151 } else { 1152 setCookie('HideWelcome', '', -1); // Remove cookie 1153 } 1154} 1155function solutionReport(lang, questionId, solutionId) { 1156 solutionUpdate(solutionId, 'report') 1157 .then(()=>showOthersSolutions(questionId)); 1158} 1159function solutionDelete(lang, questionId, solutionId) { 1160 return fetch(`/${lang}/solution/${solutionId}/delete`, { 1161 method: "POST", 1162 mode: "cors", 1163 cache: "default", 1164 credentials: "same-origin", 1165 }) 1166 .then((async response=>{ 1167 if (response.ok) { 1168 const message = await response.text(); 1169 showToast('info', message); 1170 } 1171 })) 1172 .then(()=>showMySolutions(questionId)) 1173 .catch(err=>{ 1174 showToast('error', 'Something went wrong. Please ask admin for help.'); 1175 }); 1176} 1177function solutionRun(lang, questionId, solutionId) { 1178 const solution = ace.edit(`solution-${solutionId}`).getValue(); 1179 window.sql_editor.setValue(solution); 1180 return runQuery(lang, questionId); 1181} 1182 1183function rateQuestion(questionId, rate) { 1184 let formData = new FormData(); 1185 formData.append('rate', rate); 1186 fetch(`/${lang}/question/${questionId}/rate`, { 1187 method: "POST", 1188 mode: "cors", 1189 cache: "default", 1190 credentials: "same-origin", 1191 body: formData, 1192 }) 1193 .then((async response=>{ 1194 if (response.ok) { 1195 const message = await response.text(); 1196 showToast('info', message); 1197 } 1198 })) 1199 .catch(err=>{ 1200 showToast('error', 'Something went wrong. Please ask admin for help.'); 1201 }); 1202} 1203function toggleSolvedTasks(e) { 1204 // CSS hides the solved tasks and swaps the eye icons, also in the groups loaded later 1205 document.documentElement.classList.toggle('hide-solved-tasks'); 1206 window.UIConfig.hideSolvedTasks = !window.UIConfig.hideSolvedTasks; 1207 saveUIConfig(); 1208 return false;
1209} 1210function toggleNotFavoritsTasks(e) { 1211 document.getElementById('toggleNotFavoritTasks').classList.toggle("favored"); 1212 [...document.getElementsByClassName("question-link solved")].map(el=>{ 1213 el.parentNode.classList.toggle("invisible") 1214 }); 1215 window.UIConfig.hidenotFavoredTasks = !window.UIConfig.hidenotFavoredTasks; 1216 saveUIConfig(); 1217 return false; 1218} 1219 1220function toggleInfoPanel() { 1221 document.getElementsByClassName("right")[0].classList.toggle("hidden"); 1222 document.getElementsByClassName("main")[0].classList.toggle("wide"); 1223 [...document.getElementsByClassName("splitter")[0].children].map(el=>el.classList.toggle("hidden")); 1224 window.UIConfig.hideInfoPanel = !window.UIConfig.hideInfoPanel; 1225 saveUIConfig(); 1226 return false; 1227} 1228 1229function scrollQuestionPanel() { 1230 const activePanel = document.getElementsByClassName("panel active")[0]; 1231 const qurrentQuestion = document.getElementsByClassName("current-question")[0]; 1232 if (activePanel && qurrentQuestion) { 1233 activePanel.scrollTop = qurrentQuestion.offsetTop - activePanel.offsetTop; 1234 } 1235} 1236function openLinkedinLoginPopUp() { 1237 window.open( 1238 `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=77scnm5m8z804e&redirect_uri=${window.location.protocol}//${window.location.host}/login/linkedin/&state=SignupAuth&scope=openid%20email%20profile`, 1239 'LinkedIn Login', 1240 `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=950,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}` 1241 ); 1242} 1243function openGitHubLoginPopUp() { 1244 const githubClientId = window.AppConfig?.githubClientId || ''; 1245 if (!githubClientId) { 1246 showToast('error', 'GitHub login is not configured.'); 1247 return; 1248 } 1249 1250 window.open( 1251 `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent(githubClientId)}&redirect_uri=${window.location.protocol}//${window.location.host}/login/github/&scope=user`, 1252 'GitHub Login', 1253 `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=950,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}` 1254 ); 1255} 1256function openVKLoginPopUp() { 1257 const uuid = '29890eb2-6a16-0613-190f-250e54537e18'; // Generate a random string. We recommend using at least 36 characters. This string will be used to verify that the request is coming from your app. 1258 const appId = 51931966; // Your app identifier. 1259 // const redirect_uri = `${window.location.protocol}//${window.location.host}/login/vk/`; 1260 const redirect_uri = `${window.location.protocol}//${window.location.host}/login/vk/?lang=${lang}`; 1261 const redirect_state = 'login'; // Your app's state or any arbitrary string that will be added to the URL after authentication. 1262 1263 const query = `uuid=${uuid}&app_id=${appId}&response_type=silent_token&redirect_uri=${redirect_uri}&redirect_state=${redirect_state}`; 1264 1265 window.open( 1266 `https://id.vk.com/auth?${query}`, 1267 'VK Login', 1268 `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=950,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}` 1269 ); 1270} 1271 1272// Email/Password Login Functions 1273function openEmailLoginPopUp() { 1274 document.getElementById('emailLoginPopup').style.display = 'flex'; 1275 document.getElementById('loginFormContainer').style.display = 'block'; 1276 document.getElementById('registerFormContainer').style.display = 'none'; 1277} 1278 1279function closeEmailLoginPopUp() { 1280 document.getElementById('emailLoginPopup').style.display = 'none'; 1281 document.getElementById('emailLoginForm').reset(); 1282 document.getElementById('emailRegisterForm').reset(); 1283 hideLoginError(); 1284 hideRegisterError(); 1285} 1286 1287function showLoginError(message) { 1288 const errorDiv = document.getElementById('loginErrorMessage'); 1289 errorDiv.textContent = message; 1290 errorDiv.style.display = 'block'; 1291} 1292 1293function hideLoginError() { 1294 const errorDiv = document.getElementById('loginErrorMessage'); 1295 errorDiv.style.display = 'none'; 1296 errorDiv.textContent = ''; 1297} 1298 1299function showRegisterError(message) { 1300 const errorDiv = document.getElementById('registerErrorMessage'); 1301 errorDiv.textContent = message; 1302 errorDiv.style.display = 'block'; 1303} 1304 1305function hideRegisterError() { 1306 const errorDiv = document.getElementById('registerErrorMessage'); 1307 errorDiv.style.display = 'none'; 1308 errorDiv.textContent = ''; 1309} 1310 1311function switchToRegister(event) { 1312 event.preventDefault(); 1313 hideLoginError(); 1314 document.getElementById('loginFormContainer').style.display = 'none'; 1315 document.getElementById('registerFormContainer').style.display = 'block'; 1316} 1317 1318function switchToLogin(event) { 1319 event.preventDefault(); 1320 hideRegisterError(); 1321 document.getElementById('registerFormContainer').style.display = 'none'; 1322 document.getElementById('loginFormContainer').style.display = 'block'; 1323} 1324 1325function handleEmailLogin(event) { 1326 event.preventDefault(); 1327 hideLoginError(); 1328 1329 const email = document.getElementById('loginEmail').value; 1330 const password = document.getElementById('loginPassword').value; 1331 1332 // Send login request to server 1333 const ajax = 1; 1334 fetch('/login/password/', { 1335 method: 'POST', 1336 headers: { 1337 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 1338 }, 1339 body: new URLSearchParams({ email, password, ajax, lang }).toString() 1340 }) 1341 .then(response => response.json()) 1342 .then(data => { 1343 if (data.status && data.status === 'ok') { 1344 window.location.reload(); // Reload page on successful login 1345 } else { 1346 showLoginError(data.message || 'Login failed. Please check your credentials.'); 1347 } 1348 }) 1349 .catch(error => { 1350 console.error('Error:', error); 1351 showLoginError('An error occurred during login. Please try again.'); 1352 }); 1353} 1354 1355function handleForgotPassword(event) { 1356 event.preventDefault(); 1357 hideLoginError(); 1358 1359 const email = document.getElementById('loginEmail').value.trim(); 1360 if (!email) { 1361 showLoginError('Please enter your email address first.'); 1362 return; 1363 } 1364 1365 fetch(`/${lang}/forgot-password/`, { 1366 method: 'POST', 1367 headers: { 1368 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 1369 }, 1370 body: new URLSearchParams({ email }).toString() 1371 }) 1372 .then(response => response.json()) 1373 .then(data => showLoginError(data.message || 'If an account exists, a temporary password has been sent.')) 1374 .catch(error => { 1375 console.error('Error:', error); 1376 showLoginError('An error occurred. Please try again later.'); 1377 }); 1378} 1379 1380function handleEmailRegister(event) { 1381 event.preventDefault(); 1382 hideRegisterError(); 1383 1384 const full_name = document.getElementById('registerName').value; 1385 const email = document.getElementById('registerEmail').value; 1386 const password = document.getElementById('registerPassword').value; 1387 const passwordConfirm = document.getElementById('registerPasswordConfirm').value; 1388 1389 if (password !== passwordConfirm) { 1390 showRegisterError('Passwords do not match!'); 1391 return; 1392 } 1393 1394 // Send registration request to server 1395 const ajax = 1; 1396 fetch(`/${lang}/register/`, { 1397 method: 'POST', 1398 headers: { 1399 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 1400 }, 1401 body: new URLSearchParams({ full_name, email, password, ajax, lang }).toString() 1402 }) 1403 .then(response => response.json()) 1404 .then(data => { 1405 if (data.status && data.status === 'ok') { 1406 switchToLogin(event); 1407 } else { 1408 showRegisterError(data.message || 'Registration failed. Please try again.'); 1409 } 1410 }) 1411 .catch(error => { 1412 console.error('Error:', error); 1413 showRegisterError('An error occurred during registration. Please try again.'); 1414 }); 1415} 1416 1417function openGoogleLoginPopUp() { 1418 const googleClientId = window.AppConfig?.googleClientId || document.querySelector('meta[name="google-signin-client_id"]')?.content || ''; 1419 if (!googleClientId) { 1420 showToast('error', 'Google login is not configured.'); 1421 return; 1422 } 1423 1424 const params = { 1425 response_type: 'code', 1426 client_id: googleClientId, 1427 redirect_uri: `${window.location.protocol}//${window.location.host}/login/google/`, 1428 scope:'openid email', 1429 state: '' 1430 }; 1431 window.open( 1432 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams(params).toString(), 1433 'Google Login', 1434 `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=600,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}` 1435 ); 1436} 1437 1438function saveUIConfig() { 1439 localStorage.setItem("UIConfig", JSON.stringify(window.UIConfig)); 1440} 1441function applyUIConfig() { 1442 if (window.UIConfig.hideSolvedTasks) { 1443 window.UIConfig.hideSolvedTasks = !window.UIConfig.hideSolvedTasks; 1444 toggleSolvedTasks(); 1445 } 1446 1447 if (window.UIConfig.hideInfoPanel) { 1448 window.UIConfig.hideInfoPanel = !window.UIConfig.hideInfoPanel; 1449 toggleInfoPanel(); 1450 } 1451 document.querySelector('#theme-switch-checkbox').checked = (window.UIConfig.theme === 'dark' ? 1 : 0); 1452} 1453 1454/** 1455 * Accordions (the task menu groups, the database tables in the right panel), the "hide solved" eyes and the 1456 * menu search. scope: the element whose accordions to bind (the menu after loadMenu() replaced it). 1457 */ 1458function setMenuEventListeners(scope = document) { 1459 scope.querySelectorAll(".accordion").forEach(el=>{ 1460 el.addEventListener ('click', function() { 1461 const parentElement = this.parentElement; 1462 const wasActive = this.classList.contains("active"); 1463 if (parentElement.id === 'menu-content') { 1464 //close all panels 1465 for (let el of parentElement.getElementsByClassName("panel")) el.classList.remove("active"); 1466 for (let el of parentElement.getElementsByClassName("accordion")) el.classList.remove("active"); 1467 } 1468 this.classList.toggle("active", !wasActive); 1469 const panel = this.nextElementSibling; 1470 panel.classList.toggle("active", !wasActive); 1471 if (!wasActive && panel.dataset.group && !panel.children.length) { 1472 loadMenuGroups(panel.dataset.group); 1473 } 1474 }); 1475 }); 1476 1477 scope.querySelectorAll(".eye-btn").forEach(el=>{ 1478 el.addEventListener ('click', e=>{ 1479 e.preventDefault(); 1480 // Don't collapse the group under the button 1481 e.stopPropagation(); 1482 toggleSolvedTasks() 1483 }); 1484 }); 1485 1486 const search = scope.querySelector('#menu-search-input'); 1487 if (search) { 1488 let timer = null; 1489 search.addEventListener('input', () => { 1490 clearTimeout(timer); 1491 timer = setTimeout(() => searchMenu(search.value), 150); 1492 }); 1493 } 1494} 1495 1496// Task menu groups other than the current one come without their task lists (menu.tpl); load one group, 1497// or 'all' for the search. Resolves when the lists are in place. 1498let menuGroupsRequest = null; 1499function loadMenuGroups(group) { 1500 const content = document.getElementById('menu-content'); 1501 if (!content) return Promise.resolve(); 1502 if (group === 'all' && menuGroupsRequest) return menuGroupsRequest; 1503 const questionnire = document.querySelector('input[name=menu_groups]:checked')?.value || 'category'; 1504 const request = fetch(`/${lang}/menu?questionnire=${encodeURIComponent(questionnire)}&group=${encodeURIComponent(group)}`, { 1505 credentials: "same-origin", 1506 }) 1507 .then(response => { 1508 if (!response.ok) throw Error('Something went wrong.'); 1509 return response.text(); 1510 }) 1511 .then(html => { 1512 const fill = (id, listHtml) => { 1513 const panel = content.querySelector(`.panel[data-group="${id}"]`); 1514 if (panel && !panel.children.length) panel.innerHTML = listHtml; 1515 }; 1516 if (group === 'all') { 1517 const holder = document.createElement('div'); 1518 holder.innerHTML = html; 1519 holder.querySelectorAll(':scope > [data-group]').forEach(el => fill(el.dataset.group, el.innerHTML)); 1520 } else { 1521 fill(group, html); 1522 } 1523 }) 1524 .catch(err => { 1525 if (group === 'all') menuGroupsRequest = null; 1526 console.log(err); 1527 }); 1528 if (group === 'all') menuGroupsRequest = request; 1529 return request; 1530} 1531 1532// Search by task title or number: shows the matching tasks in all groups, hides the rest 1533function searchMenu(query) { 1534 const content = document.getElementById('menu-content'); 1535 const empty = document.querySelector('.menu-search-empty'); 1536 if (!content) return; 1537 const words = query.trim().toLowerCase().split(/\s+/).filter(
1537Boolean); 1538 if (!words.length) { 1539 content.classList.remove('searching'); 1540 content.querySelectorAll('.search-match, .search-hit').forEach(el => el.classList.remove('search-match', 'search-hit')); 1541 empty?.classList.add('hidden'); 1542 return; 1543 } 1544 loadMenuGroups('all').then(() => { 1545 // The query may have changed while the lists were loading 1546 if (document.getElementById('menu-search-input')?.value.trim().toLowerCase().split(/\s+/).join(' ') !== words.join(' ')) return; 1547 content.classList.add('searching'); 1548 let found = 0; 1549 content.querySelectorAll('.panel').forEach(panel => { 1550 let panelFound = 0; 1551 panel.querySelectorAll('li').forEach(li => { 1552 const text = li.textContent.replace(/\s+/g, ' ').toLowerCase(); 1553 const hit = words.every(word => text.includes(word)); 1554 li.classList.toggle('search-hit', hit); 1555 if (hit) panelFound++; 1556 }); 1557 panel.classList.toggle('search-match', panelFound > 0); 1558 panel.previousElementSibling?.classList.toggle('search-match', panelFound > 0); 1559 found += panelFound; 1560 }); 1561 empty?.classList.toggle('hidden', found > 0); 1562 }); 1563} 1564 1565function setEventListeners() { 1566 [...document.querySelectorAll(".db-description .sql")].map(el=>{ 1567 el.addEventListener ('dblclick', e=>{ 1568 e.preventDefault(); 1569 window.sql_editor.session.insert(window.sql_editor.getCursorPosition(), ` ${el.innerText} `); 1570 window.sql_editor.focus(); 1571 }); 1572 }) 1573 const toggleSwitch = document.querySelector('#theme-switch-checkbox'); 1574 toggleSwitch.addEventListener('change', switchTheme, false); 1575 1576 const link = document.querySelector("a[target='ERDWindow']"); 1577 if (link) { 1578 link.addEventListener( 1579 "click", 1580 (event) => { 1581 openRequestedTab(link.href); 1582 event.preventDefault(); 1583 }, 1584 false, 1585 ); 1586 } 1587} 1588 1589setMenuEventListeners(); 1590setEventListeners(); 1591document.querySelectorAll('.new-achievement').forEach(initNewAchievement); 1592applyUIConfig(); 1593lazyInitShareThis(); 1594if (document.getElementById("sql-code")) { 1595 1596 window.sql_editor = ace.edit("sql-code", { 1597 mode: "ace/mode/mysql", 1598 theme: getAceTheme(window.UIConfig.theme), 1599 selectionStyle: "text", 1600 dragEnabled: false, 1601 useWorker: false 1602 }); 1603 1604 window.sql_editor.setShowPrintMargin(false); 1605 window.sql_editor.setOptions({enableBasicAutocompletion: true}); 1606 // Shown while the editor is empty (data-placeholder: index.tpl, m.index.tpl) 1607 const editorPlaceholder = document.getElementById("sql-code").dataset.placeholder; 1608 if (editorPlaceholder) { 1609 window.sql_editor.setOption("placeholder", editorPlaceholder); 1610 } 1611} 1612 1613window.onload = function() { 1614 scrollQuestionPanel(); 1615 if (!getSpeechRecognitionClass() && document.getElementById('voiceInputBtn')) { 1616 document.getElementById('voiceInputBtn').classList.add('hidden'); 1617 } 1618 document.addEventListener('keydown', function(event) { 1619 if (event.ctrlKey && event.key === 'Enter' && window.sql_editor) { 1620 runQuery(lang, questionId); 1621 } 1622 // if (event.altKey && event.key === 'Enter') { 1623 // testQuery(lang, questionId); 1624 // } 1625 }); 1626 1627 // Close mobile menu when clicking outside 1628 document.addEventListener('click', function(event) { 1629 const mobileMenuDropdown = document.getElementById('mobileMenuDropdown'); 1630 const mobileMenuToggle = document.getElementById('mobileMenuToggle'); 1631 1632 if (mobileMenuDropdown && mobileMenuToggle && 1633 !mobileMenuDropdown.contains(event.target) && 1634 !mobileMenuToggle.contains(event.target)) { 1635 mobileMenuDropdown.classList.add('hidden'); 1636 } 1637 }); 1638 1639 if (document.getElementById('yandexLogin') && window.YaAuthSuggest) { 1640 window.YaAuthSuggest.init( 1641 { 1642 client_id: '6a7ad9d0d23a496987255a596b83b9db', 1643 response_type: 'code', 1644 redirect_uri: `${window.location.protocol}//${window.location.host}/login/yandex/?lang=${lang}&db=${db}&questionId=${questionId}` 1645 }, 1646 `${window.location.protocol}//${window.location.host}/login/yandex/?lang=${lang}&db=${db}&questionId=${questionId}`, 1647 { 1648 view: "button", 1649 parentId: "yandexLogin", 1650 buttonSize: 'xl', 1651 buttonView: 'main', 1652 buttonTheme: 'light', 1653 buttonBorderRadius: "6", 1654 buttonIcon: 'ya', 1655 } 1656 ) 1657 .then(({handler}) => handler()) 1658 .catch(error => alert('ÐбÑабоÑка оÑибки', error)); 1659 } 1660 1661};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.