605 lines
25 KiB
HTML
605 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Linux Learning Lab</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f3f7fb;
|
|
--panel: rgba(255, 255, 255, 0.94);
|
|
--line: #d8e2ee;
|
|
--text: #102033;
|
|
--muted: #5d7187;
|
|
--brand: #0f6db6;
|
|
--accent: #1d9b6c;
|
|
--soft: #e9f3ff;
|
|
--terminal: #0e1724;
|
|
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12);
|
|
}
|
|
[data-theme="dark"] {
|
|
--bg: #08111d;
|
|
--panel: rgba(12, 24, 38, 0.94);
|
|
--line: #1d3245;
|
|
--text: #edf4fb;
|
|
--muted: #9bb0c3;
|
|
--brand: #67baff;
|
|
--accent: #62d6ab;
|
|
--soft: rgba(103, 186, 255, 0.1);
|
|
--terminal: #06101c;
|
|
--shadow: 0 20px 44px rgba(0, 0, 0, 0.34);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
color: var(--text);
|
|
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
background:
|
|
radial-gradient(circle at top right, rgba(15, 109, 182, 0.15), transparent 25%),
|
|
radial-gradient(circle at bottom left, rgba(29, 155, 108, 0.12), transparent 20%),
|
|
var(--bg);
|
|
}
|
|
button, input { font: inherit; }
|
|
.shell { max-width: 1480px; margin: 0 auto; padding: 20px; }
|
|
.topbar, .card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
.topbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 20px 24px;
|
|
margin-bottom: 16px;
|
|
align-items: center;
|
|
}
|
|
.title h1 { margin: 0 0 8px; font-size: 30px; }
|
|
.title p, .muted { color: var(--muted); line-height: 1.8; }
|
|
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
.btn, .btn-soft, .btn-ghost {
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 11px 16px;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
}
|
|
.btn { color: #fff; background: linear-gradient(135deg, var(--brand), #37a1ff); }
|
|
.btn-soft { color: var(--brand); background: var(--soft); }
|
|
.btn-ghost { color: var(--text); background: transparent; border: 1px solid var(--line); }
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: 360px minmax(0, 1fr);
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
.sidebar { padding: 20px; position: sticky; top: 20px; }
|
|
.section { margin-bottom: 18px; }
|
|
.eyebrow { font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--brand); }
|
|
h2 { margin: 8px 0 10px; font-size: 18px; }
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
.stat {
|
|
padding: 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.3);
|
|
}
|
|
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
|
.stat strong { font-size: 22px; }
|
|
.search {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
.search input {
|
|
flex: 1;
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
background: transparent;
|
|
color: var(--text);
|
|
outline: none;
|
|
}
|
|
.chip-list, .cmd-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 7px 12px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
background: var(--soft);
|
|
color: var(--brand);
|
|
}
|
|
.results, .modules { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; max-height: 34vh; overflow: auto; }
|
|
.result, .lesson-btn, .module-card {
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
background: rgba(255,255,255,0.18);
|
|
}
|
|
.result, .lesson-btn {
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 12px 14px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
.result strong, .lesson-btn strong { display: block; margin-bottom: 4px; font-size: 14px; }
|
|
.module-card { padding: 14px; }
|
|
.module-card h3 { margin: 0 0 8px; font-size: 16px; }
|
|
.module-card p { margin: 0 0 10px; color: var(--muted); line-height: 1.7; }
|
|
.lesson-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.lesson-btn.active { background: rgba(15, 109, 182, 0.12); border-color: rgba(15, 109, 182, 0.28); }
|
|
.main { display: flex; flex-direction: column; gap: 16px; }
|
|
.hero, .detail, .practice { padding: 22px; }
|
|
.hero-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
align-items: flex-start;
|
|
margin-bottom: 14px;
|
|
}
|
|
.hero-head h2 { margin: 8px 0 10px; font-size: 34px; }
|
|
.hero-grid, .detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 0.8fr;
|
|
gap: 14px;
|
|
}
|
|
.panel {
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
padding: 16px;
|
|
background: rgba(255,255,255,0.12);
|
|
}
|
|
.panel h3 { margin: 0 0 10px; font-size: 17px; }
|
|
.panel p, .panel li { color: var(--muted); line-height: 1.9; }
|
|
.panel ul { margin: 0; padding-left: 18px; }
|
|
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.code {
|
|
margin: 0;
|
|
padding: 13px 14px;
|
|
border-radius: 14px;
|
|
background: var(--terminal);
|
|
color: #d8e8ff;
|
|
font-family: Consolas, "Courier New", monospace;
|
|
font-size: 14px;
|
|
overflow: auto;
|
|
}
|
|
.examples { display: flex; flex-direction: column; gap: 10px; }
|
|
.exercise-list { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
|
|
.exercise {
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
padding: 16px;
|
|
background: rgba(255,255,255,0.12);
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
margin-bottom: 10px;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(29, 155, 108, 0.12);
|
|
color: var(--accent);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
.exercise h4 { margin: 0 0 10px; font-size: 20px; }
|
|
.terminal {
|
|
margin-top: 12px;
|
|
border-radius: 16px;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(151,177,201,0.18);
|
|
background: linear-gradient(180deg, var(--terminal), #172435);
|
|
}
|
|
.terminal-head { padding: 11px 14px; color: #b8d4ef; border-bottom: 1px solid rgba(151,177,201,0.16); }
|
|
.terminal-input { display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
|
|
.prompt { color: #62d6ab; font-family: Consolas, "Courier New", monospace; font-weight: 800; }
|
|
.terminal-input input {
|
|
flex: 1;
|
|
border: 0;
|
|
outline: none;
|
|
background: transparent;
|
|
color: #f4fbff;
|
|
font-family: Consolas, "Courier New", monospace;
|
|
}
|
|
.terminal-input button {
|
|
border: 0;
|
|
border-radius: 12px;
|
|
padding: 9px 13px;
|
|
background: linear-gradient(135deg, var(--brand), #37a1ff);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-weight: 800;
|
|
}
|
|
.output {
|
|
margin: 0;
|
|
min-height: 100px;
|
|
padding: 14px;
|
|
white-space: pre-wrap;
|
|
color: #d8e8ff;
|
|
font-family: Consolas, "Courier New", monospace;
|
|
}
|
|
.feedback { display: none; margin-top: 10px; padding: 11px 13px; border-radius: 14px; line-height: 1.7; font-weight: 700; }
|
|
.feedback.show { display: block; }
|
|
.feedback.success { background: rgba(29,155,108,0.1); border: 1px solid rgba(29,155,108,0.22); color: #187653; }
|
|
.feedback.warn { background: rgba(240,180,41,0.1); border: 1px solid rgba(240,180,41,0.24); color: #a26b04; }
|
|
.empty { padding: 12px; border: 1px dashed var(--line); border-radius: 14px; color: var(--muted); text-align: center; }
|
|
@media (max-width: 1180px) {
|
|
.layout { grid-template-columns: 1fr; }
|
|
.sidebar { position: static; }
|
|
.hero-grid, .detail-grid { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 720px) {
|
|
.shell { padding: 14px; }
|
|
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
|
|
.stats { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<header class="topbar">
|
|
<div class="title">
|
|
<div class="eyebrow">Linux Ops Learning Lab</div>
|
|
<h1 id="courseTitle">Linux Learning Lab</h1>
|
|
<p id="courseDescription">Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn-ghost" type="button" id="themeBtn">Toggle theme</button>
|
|
<button class="btn-soft" type="button" onclick="resetSandbox()">Reset sandbox</button>
|
|
<a class="btn-ghost" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<aside class="sidebar card">
|
|
<section class="section">
|
|
<div class="eyebrow">Overview</div>
|
|
<h2>Course overview</h2>
|
|
<p class="muted">Lessons are grouped by module and command family. The page focuses on readable summaries and guided sandbox practice.</p>
|
|
<div class="stats">
|
|
<div class="stat"><span>Modules</span><strong id="moduleCount">0</strong></div>
|
|
<div class="stat"><span>Lessons</span><strong id="lessonCount">0</strong></div>
|
|
<div class="stat"><span>Exercises</span><strong id="exerciseCount">0</strong></div>
|
|
<div class="stat"><span>Commands</span><strong id="commandCount">0</strong></div>
|
|
</div>
|
|
<div class="chip-list" id="commandTags"></div>
|
|
<p class="muted" id="metaLine" style="margin-top: 12px;">Version --</p>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="eyebrow">Search</div>
|
|
<h2>Quick lookup</h2>
|
|
<div class="search">
|
|
<input id="searchInput" type="text" placeholder="Try pwd, grep, module_1, or m1_l1_pwd" />
|
|
<button class="btn" type="button" onclick="runSearch()">Search</button>
|
|
</div>
|
|
<div class="results" id="searchResults"><div class="empty">Search by command, lesson id, or module id.</div></div>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="eyebrow">Modules</div>
|
|
<h2>Course map</h2>
|
|
<div class="modules" id="moduleList"><div class="empty">Loading modules...</div></div>
|
|
</section>
|
|
</aside>
|
|
|
|
<main class="main">
|
|
<section class="hero card">
|
|
<div class="hero-head">
|
|
<div>
|
|
<div class="eyebrow" id="moduleLabel">Pick a lesson to begin</div>
|
|
<h2 id="lessonTitle">Turn Linux practice into workflow thinking</h2>
|
|
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
|
|
<button class="btn" type="button" id="nextBtn">Next</button>
|
|
</div>
|
|
</div>
|
|
<div class="cmd-list" id="lessonCommands"></div>
|
|
<div class="hero-grid">
|
|
<div class="panel">
|
|
<h3>Module summary</h3>
|
|
<p id="moduleSummary">This card shows the learning direction of the current module.</p>
|
|
</div>
|
|
<div class="panel">
|
|
<h3>Sandbox runtime</h3>
|
|
<p>You can execute commands directly below. The platform returns simulated output and exercise feedback.</p>
|
|
<ul>
|
|
<li>Current directory: <span id="runtimeCwd">/</span></li>
|
|
<li>Current user: <span id="runtimeUser">sandbox_user</span></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="detail card">
|
|
<div class="detail-grid">
|
|
<article class="panel">
|
|
<h3>Why this matters</h3>
|
|
<p id="lessonWhy">Understanding when to use a command matters more than memorizing flags in isolation.</p>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Teaching angle</h3>
|
|
<p id="classicView">Each lesson connects the command to a broader troubleshooting flow.</p>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Core ideas</h3>
|
|
<ul id="conceptList"></ul>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Common scenarios</h3>
|
|
<ul id="scenarioList"></ul>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Common pitfalls</h3>
|
|
<ul id="pitfallList"></ul>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Troubleshooting flow</h3>
|
|
<ul id="flowList"></ul>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Takeaways</h3>
|
|
<ul id="takeawayList"></ul>
|
|
<p id="afterClass" style="margin-top: 10px;"></p>
|
|
</article>
|
|
<article class="panel">
|
|
<h3>Example commands</h3>
|
|
<div class="examples" id="exampleList"></div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="practice card">
|
|
<div class="eyebrow">Practice</div>
|
|
<h2 style="margin: 8px 0 0;">Exercises and sandbox terminal</h2>
|
|
<p class="muted">Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.</p>
|
|
<div class="exercise-list" id="exerciseList"></div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const state = { overview: null, lesson: null, theme: localStorage.getItem('linux_lab_theme') || 'light' };
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
setTheme(state.theme);
|
|
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
|
|
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') runSearch();
|
|
});
|
|
document.getElementById('prevBtn').addEventListener('click', () => {
|
|
if (state.lesson?.previous_lesson) openLesson(state.lesson.previous_lesson.id);
|
|
});
|
|
document.getElementById('nextBtn').addEventListener('click', () => {
|
|
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
|
|
});
|
|
await loadOverview();
|
|
});
|
|
|
|
async function loadOverview() {
|
|
const response = await fetch('/api/overview');
|
|
state.overview = await response.json();
|
|
renderOverview();
|
|
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
|
|
if (firstLesson) await openLesson(firstLesson.id);
|
|
}
|
|
|
|
function renderOverview() {
|
|
const meta = state.overview.meta || {};
|
|
document.getElementById('courseTitle').textContent = meta.title || 'Linux Learning Lab';
|
|
document.getElementById('courseDescription').textContent = meta.description || '';
|
|
document.getElementById('moduleCount').textContent = meta.module_count || 0;
|
|
document.getElementById('lessonCount').textContent = meta.lesson_count || 0;
|
|
document.getElementById('exerciseCount').textContent = meta.exercise_count || 0;
|
|
document.getElementById('commandCount').textContent = meta.command_count || 0;
|
|
document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`;
|
|
renderRuntime(state.overview.runtime || {});
|
|
renderCommandTags(state.overview.commands || []);
|
|
renderModules();
|
|
}
|
|
|
|
function renderRuntime(runtime) {
|
|
document.getElementById('runtimeCwd').textContent = runtime.cwd || '/';
|
|
document.getElementById('runtimeUser').textContent = runtime.user || 'sandbox_user';
|
|
}
|
|
|
|
function renderCommandTags(commands) {
|
|
const container = document.getElementById('commandTags');
|
|
if (!commands.length) {
|
|
container.innerHTML = '<span class="chip">Waiting for command index</span>';
|
|
return;
|
|
}
|
|
container.innerHTML = commands.slice(0, 10).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
|
|
}
|
|
|
|
function renderModules() {
|
|
const modules = state.overview.modules || [];
|
|
const container = document.getElementById('moduleList');
|
|
if (!modules.length) {
|
|
container.innerHTML = '<div class="empty">No modules found.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = modules.map((module) => `
|
|
<article class="module-card">
|
|
<h3>${escapeHtml(module.display_title)}</h3>
|
|
<p>${escapeHtml(module.display_summary)}</p>
|
|
<div class="lesson-list">
|
|
${(module.lessons || []).map((lesson) => `
|
|
<button class="lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''}" type="button" onclick="openLesson('${lesson.id}')">
|
|
<strong>${escapeHtml(lesson.display_title)}</strong>
|
|
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || 'mixed commands')} | ${lesson.exercise_count || 0} exercises</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
</article>
|
|
`).join('');
|
|
}
|
|
|
|
async function openLesson(lessonId, focusExerciseId = '') {
|
|
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId));
|
|
const payload = await response.json();
|
|
state.lesson = payload.lesson;
|
|
renderModules();
|
|
renderLesson();
|
|
if (focusExerciseId) {
|
|
setTimeout(() => {
|
|
document.getElementById('exercise-' + focusExerciseId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}, 120);
|
|
}
|
|
}
|
|
|
|
function renderLesson() {
|
|
const lesson = state.lesson;
|
|
if (!lesson) return;
|
|
document.getElementById('moduleLabel').textContent = lesson.display_module_title || 'Current lesson';
|
|
document.getElementById('lessonTitle').textContent = lesson.display_title || 'Linux lesson';
|
|
document.getElementById('lessonGoal').textContent = lesson.display_goal || '';
|
|
document.getElementById('moduleSummary').textContent = lesson.display_module_summary || '';
|
|
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
|
|
document.getElementById('classicView').textContent = lesson.classic_view || '';
|
|
document.getElementById('afterClass').textContent = lesson.after_class || '';
|
|
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
|
|
document.getElementById('nextBtn').disabled = !lesson.next_lesson;
|
|
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || 'mixed commands']).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
|
|
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
|
|
renderList('scenarioList', lesson.scenarios, 'No scenario notes yet.');
|
|
renderList('pitfallList', lesson.pitfalls, 'No pitfall notes yet.');
|
|
renderList('flowList', lesson.troubleshooting_flow, 'No flow notes yet.');
|
|
renderList('takeawayList', lesson.takeaways, 'No takeaways yet.');
|
|
const examples = lesson.examples || [];
|
|
document.getElementById('exampleList').innerHTML = examples.length ? examples.map((item) => `<pre class="code">${escapeHtml(item)}</pre>`).join('') : '<div class="empty">No example commands yet.</div>';
|
|
renderExercises(lesson.exercises || []);
|
|
}
|
|
|
|
function renderList(id, items, emptyText) {
|
|
const target = document.getElementById(id);
|
|
target.innerHTML = (items && items.length) ? items.map((item) => `<li>${escapeHtml(item)}</li>`).join('') : `<li>${escapeHtml(emptyText)}</li>`;
|
|
}
|
|
|
|
function renderExercises(exercises) {
|
|
const container = document.getElementById('exerciseList');
|
|
if (!exercises.length) {
|
|
container.innerHTML = '<div class="empty">This lesson does not have exercises yet.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = exercises.map((exercise) => {
|
|
const label = exercise.type === 'operation' ? 'Operation task' : exercise.type === 'scenario' ? 'Scenario reflection' : 'Understanding task';
|
|
if (exercise.type === 'operation') {
|
|
const placeholder = exercise.solution?.[0] || state.lesson.command || 'pwd';
|
|
return `
|
|
<article class="exercise" id="exercise-${exercise.id}">
|
|
<span class="badge">${label}</span>
|
|
<h4>${escapeHtml(exercise.title || 'Operation task')}</h4>
|
|
<p class="muted">${escapeHtml(exercise.hint || 'Enter a command and inspect the output.')}</p>
|
|
<div class="terminal">
|
|
<div class="terminal-head">Linux sandbox terminal</div>
|
|
<div class="terminal-input">
|
|
<span class="prompt">$</span>
|
|
<input id="input-${exercise.id}" type="text" placeholder="${escapeHtml(placeholder)}" onkeydown="if(event.key==='Enter'){runExercise('${exercise.id}')}" />
|
|
<button type="button" onclick="runExercise('${exercise.id}')">Run</button>
|
|
</div>
|
|
<pre class="output" id="output-${exercise.id}">Waiting for a command...</pre>
|
|
</div>
|
|
<div class="feedback" id="feedback-${exercise.id}"></div>
|
|
</article>
|
|
`;
|
|
}
|
|
return `
|
|
<article class="exercise" id="exercise-${exercise.id}">
|
|
<span class="badge">${label}</span>
|
|
<h4>${escapeHtml(exercise.question || 'Reflection task')}</h4>
|
|
<p>${escapeHtml(exercise.answer || 'Summarize the purpose, output, and next step in your own words.')}</p>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function runExercise(exerciseId) {
|
|
const input = document.getElementById('input-' + exerciseId);
|
|
const output = document.getElementById('output-' + exerciseId);
|
|
const feedback = document.getElementById('feedback-' + exerciseId);
|
|
const cmd = input.value.trim();
|
|
if (!cmd) return;
|
|
output.textContent = `$ ${cmd}\n\nRunning...`;
|
|
feedback.className = 'feedback';
|
|
feedback.textContent = '';
|
|
try {
|
|
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
|
|
const runData = await runResponse.json();
|
|
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(no output)'}`;
|
|
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
|
|
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
|
|
const checkData = await checkResponse.json();
|
|
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
|
|
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
|
|
} catch (error) {
|
|
output.textContent = `$ ${cmd}\n\nRequest failed: ${error.message}`;
|
|
feedback.className = 'feedback show warn';
|
|
feedback.textContent = 'The request failed. Please try again.';
|
|
}
|
|
}
|
|
|
|
async function resetSandbox() {
|
|
await fetch('/api/reset', { method: 'POST' });
|
|
renderRuntime({ cwd: '/', user: 'sandbox_user' });
|
|
alert('Sandbox reset complete.');
|
|
}
|
|
|
|
async function runSearch() {
|
|
const query = document.getElementById('searchInput').value.trim();
|
|
const container = document.getElementById('searchResults');
|
|
if (!query) {
|
|
container.innerHTML = '<div class="empty">Search by command, lesson id, or module id.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = '<div class="empty">Searching...</div>';
|
|
const response = await fetch('/api/course/search?q=' + encodeURIComponent(query));
|
|
const payload = await response.json();
|
|
const results = payload.results || [];
|
|
if (!results.length) {
|
|
container.innerHTML = '<div class="empty">No results found. Try a command name or lesson id.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = results.map((item) => `
|
|
<button class="result" type="button" onclick="openLesson('${item.lesson_id}', '${item.exercise_id || ''}')">
|
|
<strong>${escapeHtml(item.title)}</strong>
|
|
<span class="muted">${escapeHtml(item.subtitle || item.type)}</span>
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
state.theme = theme;
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
localStorage.setItem('linux_lab_theme', theme);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
window.openLesson = openLesson;
|
|
window.runExercise = runExercise;
|
|
window.runSearch = runSearch;
|
|
window.resetSandbox = resetSandbox;
|
|
</script>
|
|
</body>
|
|
</html>
|