263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
const lessons = [
|
|
{
|
|
id: 'go-json-parse',
|
|
title: 'Parse JSON into a Go struct',
|
|
language: 'Go',
|
|
tags: ['json', 'struct', 'api'],
|
|
excerpt: 'Use encoding/json with exported struct fields and explicit error handling.',
|
|
summary: 'Go parses JSON by unmarshalling bytes into a struct. Field names must be exported, and struct tags let you map JSON keys to Go names.',
|
|
code: `type Payload struct {\n Name string \`json:"name"\`\n}\n\nvar p Payload\nif err := json.Unmarshal(body, &p); err != nil {\n return err\n}`
|
|
},
|
|
{
|
|
id: 'js-async-await',
|
|
title: 'Use async/await for readable JavaScript APIs',
|
|
language: 'JavaScript',
|
|
tags: ['async', 'fetch', 'promises'],
|
|
excerpt: 'Wrap fetch calls in async functions, await the response, then handle errors explicitly.',
|
|
summary: 'async/await keeps asynchronous JavaScript code close to synchronous control flow. Always check response.ok and catch failures near the calling boundary.',
|
|
code: `async function loadUser(id) {\n const res = await fetch(\`/api/users/\${id}\`);\n if (!res.ok) throw new Error('Request failed');\n return await res.json();\n}`
|
|
},
|
|
{
|
|
id: 'python-list-comprehension',
|
|
title: 'Filter and transform Python lists',
|
|
language: 'Python',
|
|
tags: ['lists', 'comprehension', 'clean code'],
|
|
excerpt: 'List comprehensions are concise when the expression and predicate stay simple.',
|
|
summary: 'Use a list comprehension when you can express mapping and filtering in one readable line. Prefer a normal loop when you need multiple steps or side effects.',
|
|
code: `names = ['Ada', 'Linus', 'Grace']\nshort_names = [name.lower() for name in names if len(name) <= 5]`
|
|
},
|
|
{
|
|
id: 'sql-joins',
|
|
title: 'Choose the right SQL join',
|
|
language: 'SQL',
|
|
tags: ['joins', 'select', 'relational'],
|
|
excerpt: 'INNER JOIN returns matching rows; LEFT JOIN keeps all rows from the left table.',
|
|
summary: 'Start with INNER JOIN when both records must exist. Use LEFT JOIN for optional related records, such as users without orders yet.',
|
|
code: `SELECT users.name, orders.total\nFROM users\nLEFT JOIN orders ON orders.user_id = users.id\nWHERE users.active = 1;`
|
|
},
|
|
{
|
|
id: 'php-pdo-prepared',
|
|
title: 'Run safe PHP PDO queries',
|
|
language: 'PHP',
|
|
tags: ['pdo', 'security', 'mysql'],
|
|
excerpt: 'Prepared statements prevent SQL injection and keep user input out of raw SQL strings.',
|
|
summary: 'Create a PDO statement, bind values, execute it, and fetch typed results. Never concatenate untrusted request input into SQL.',
|
|
code: `$stmt = $pdo->prepare('SELECT * FROM posts WHERE slug = :slug');\n$stmt->execute(['slug' => $slug]);\n$post = $stmt->fetch(PDO::FETCH_ASSOC);`
|
|
},
|
|
{
|
|
id: 'ts-generics',
|
|
title: 'Model reusable TypeScript functions with generics',
|
|
language: 'TypeScript',
|
|
tags: ['types', 'generics', 'safety'],
|
|
excerpt: 'Generics preserve specific types while keeping helpers reusable.',
|
|
summary: 'Use a generic type parameter when the function should work with many input types but return or store the same specific type information.',
|
|
code: `function first<T>(items: T[]): T | undefined {\n return items[0];\n}\n\nconst value = first<string>(['docs', 'code']);`
|
|
}
|
|
];
|
|
|
|
const els = {};
|
|
let activeResults = [];
|
|
let selectedId = null;
|
|
let toastInstance = null;
|
|
|
|
function $(selector) { return document.querySelector(selector); }
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>"']/g, (char) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
|
}
|
|
|
|
function scoreLesson(lesson, query, language) {
|
|
const q = query.trim().toLowerCase();
|
|
const haystack = `${lesson.title} ${lesson.language} ${lesson.tags.join(' ')} ${lesson.excerpt} ${lesson.summary}`.toLowerCase();
|
|
let score = 0;
|
|
if (language === 'all' || lesson.language === language) score += 25;
|
|
if (language !== 'all' && lesson.language !== language) return 0;
|
|
if (!q) return score;
|
|
const terms = q.split(/\s+/).filter(Boolean);
|
|
terms.forEach((term) => {
|
|
if (lesson.title.toLowerCase().includes(term)) score += 18;
|
|
if (lesson.tags.join(' ').toLowerCase().includes(term)) score += 12;
|
|
if (haystack.includes(term)) score += 6;
|
|
});
|
|
return score;
|
|
}
|
|
|
|
function searchLessons(query, language) {
|
|
return lessons
|
|
.map((lesson) => ({ ...lesson, score: scoreLesson(lesson, query, language) }))
|
|
.filter((lesson) => lesson.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
|
}
|
|
|
|
function renderResults(results) {
|
|
activeResults = results;
|
|
els.resultCount.textContent = results.length ? `${results.length} result${results.length === 1 ? '' : 's'} ranked by relevance.` : 'No matches yet. Try another language or broader topic.';
|
|
if (!results.length) {
|
|
const hasQuery = els.query && els.query.value.trim().length >= 2;
|
|
els.resultsList.innerHTML = hasQuery
|
|
? `<div class="empty-state py-5"><div class="empty-icon" aria-hidden="true">?</div><h2 class="h5">No results found</h2><p class="text-secondary mb-0">Try “JSON”, “async”, “join”, or choose All languages.</p></div>`
|
|
: `<div class="empty-state py-5"><div class="empty-icon" aria-hidden="true">/</div><h2 class="h5">Start with a query</h2><p class="text-secondary mb-0">Search a topic or choose a suggested chip to preview the knowledge workflow.</p></div>`;
|
|
renderEmptyDetail();
|
|
return;
|
|
}
|
|
els.resultsList.innerHTML = results.map((lesson, index) => `
|
|
<button class="result-item ${lesson.id === selectedId ? 'active' : ''}" type="button" data-id="${escapeHtml(lesson.id)}" aria-label="Open ${escapeHtml(lesson.title)}">
|
|
<div class="d-flex justify-content-between gap-3">
|
|
<div class="result-title">${escapeHtml(lesson.title)}</div>
|
|
<div class="score">#${index + 1}</div>
|
|
</div>
|
|
<p class="result-excerpt">${escapeHtml(lesson.excerpt)}</p>
|
|
<div>${[lesson.language, ...lesson.tags].map(tag => `<span class="badge-soft">${escapeHtml(tag)}</span>`).join('')}</div>
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
function renderEmptyDetail() {
|
|
selectedId = null;
|
|
els.detail.innerHTML = `<div class="empty-state"><div class="empty-icon" aria-hidden="true">⌘</div><h2 class="h4">Choose a result</h2><p class="text-secondary mb-0">Open a result to see a concise explanation, tags, and copy-ready code examples.</p></div>`;
|
|
}
|
|
|
|
function renderDetail(id) {
|
|
const lesson = lessons.find(item => item.id === id);
|
|
if (!lesson) return renderEmptyDetail();
|
|
selectedId = id;
|
|
els.detail.innerHTML = `
|
|
<div class="detail-meta">
|
|
<span class="badge-soft">${escapeHtml(lesson.language)}</span>
|
|
${lesson.tags.map(tag => `<span class="badge-soft">${escapeHtml(tag)}</span>`).join('')}
|
|
</div>
|
|
<h2 class="h3 mb-3">${escapeHtml(lesson.title)}</h2>
|
|
<p class="detail-summary">${escapeHtml(lesson.summary)}</p>
|
|
<h3 class="h6 mt-4">Code example</h3>
|
|
<pre class="code-block"><code>${escapeHtml(lesson.code)}</code></pre>
|
|
<div class="detail-actions">
|
|
<button type="button" class="btn btn-dark btn-sm" data-copy-code="${escapeHtml(lesson.id)}">Copy code</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-share-result="${escapeHtml(lesson.id)}">Share result</button>
|
|
</div>
|
|
`;
|
|
renderResults(activeResults);
|
|
updateUrl({ result: id });
|
|
}
|
|
|
|
function updateUrl(extra = {}) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const query = els.query.value.trim();
|
|
const language = els.language.value;
|
|
if (query) params.set('q', query); else params.delete('q');
|
|
if (language && language !== 'all') params.set('language', language); else params.delete('language');
|
|
if (extra.result) params.set('result', extra.result);
|
|
const next = `${window.location.pathname}${params.toString() ? `?${params}` : ''}#search`;
|
|
history.replaceState({}, '', next);
|
|
}
|
|
|
|
function showToast(message) {
|
|
els.toastMessage.textContent = message;
|
|
if (window.bootstrap && toastInstance) toastInstance.show();
|
|
}
|
|
|
|
function submitSearch(showNotification = true) {
|
|
const query = els.query.value.trim();
|
|
if (query.length < 2) {
|
|
els.form.classList.add('was-validated');
|
|
els.query.focus();
|
|
return;
|
|
}
|
|
els.form.classList.remove('was-validated');
|
|
selectedId = null;
|
|
const results = searchLessons(query, els.language.value);
|
|
renderResults(results);
|
|
if (results[0]) renderDetail(results[0].id);
|
|
updateUrl();
|
|
if (showNotification) showToast('Search complete. Results ranked locally for the MVP demo.');
|
|
}
|
|
|
|
async function copyText(text, message) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
showToast(message);
|
|
} catch (error) {
|
|
showToast('Copy failed. You can manually copy from the page.');
|
|
}
|
|
}
|
|
|
|
function initFromUrl() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const q = params.get('q');
|
|
const language = params.get('language');
|
|
const result = params.get('result');
|
|
if (q) els.query.value = q;
|
|
if (language && [...els.language.options].some(option => option.value === language || option.textContent === language)) els.language.value = language;
|
|
if (q && q.length >= 2) {
|
|
const results = searchLessons(q, els.language.value);
|
|
renderResults(results);
|
|
renderDetail(result && lessons.some(item => item.id === result) ? result : (results[0]?.id || null));
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
els.form.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
submitSearch();
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const resultButton = event.target.closest('[data-id]');
|
|
if (resultButton) {
|
|
renderDetail(resultButton.dataset.id);
|
|
document.getElementById('detail').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
return;
|
|
}
|
|
|
|
const chip = event.target.closest('.chip');
|
|
if (chip) {
|
|
els.query.value = chip.dataset.query || '';
|
|
els.language.value = chip.dataset.language || 'all';
|
|
submitSearch();
|
|
return;
|
|
}
|
|
|
|
const languageCard = event.target.closest('.language-card');
|
|
if (languageCard) {
|
|
els.language.value = languageCard.dataset.language;
|
|
els.query.value = languageCard.dataset.language;
|
|
submitSearch();
|
|
document.getElementById('search').scrollIntoView({ behavior: 'smooth' });
|
|
return;
|
|
}
|
|
|
|
const copyCode = event.target.closest('[data-copy-code]');
|
|
if (copyCode) {
|
|
const lesson = lessons.find(item => item.id === copyCode.dataset.copyCode);
|
|
if (lesson) copyText(lesson.code, 'Code example copied.');
|
|
return;
|
|
}
|
|
|
|
const shareResult = event.target.closest('[data-share-result]');
|
|
if (shareResult) {
|
|
updateUrl({ result: shareResult.dataset.shareResult });
|
|
copyText(window.location.href, 'Shareable result link copied.');
|
|
}
|
|
});
|
|
|
|
els.copySearchLink.addEventListener('click', () => {
|
|
updateUrl(selectedId ? { result: selectedId } : {});
|
|
copyText(window.location.href, 'Shareable search link copied.');
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
els.form = $('#searchForm');
|
|
els.query = $('#queryInput');
|
|
els.language = $('#languageFilter');
|
|
els.resultsList = $('#resultsList');
|
|
els.resultCount = $('#resultCount');
|
|
els.detail = $('#detail');
|
|
els.copySearchLink = $('#copySearchLink');
|
|
els.toastMessage = $('#toastMessage');
|
|
const toastEl = $('#appToast');
|
|
if (window.bootstrap && toastEl) toastInstance = new bootstrap.Toast(toastEl, { delay: 2800 });
|
|
renderResults([]);
|
|
bindEvents();
|
|
initFromUrl();
|
|
});
|