feat: rebuild linux learning cockpit
This commit is contained in:
2491
COURSE_TASKS.json
2491
COURSE_TASKS.json
File diff suppressed because it is too large
Load Diff
148
index.html
148
index.html
@@ -94,6 +94,25 @@
|
|||||||
}
|
}
|
||||||
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||||
.stat strong { font-size: 22px; }
|
.stat strong { font-size: 22px; }
|
||||||
|
.mini-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.mini-stat {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
.mini-stat span {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.mini-stat strong { font-size: 20px; }
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -231,16 +250,37 @@
|
|||||||
.feedback.show { display: block; }
|
.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.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; }
|
.feedback.warn { background: rgba(240,180,41,0.1); border: 1px solid rgba(240,180,41,0.24); color: #a26b04; }
|
||||||
|
.mastery-note {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.lesson-btn.done {
|
||||||
|
border-color: rgba(29, 155, 108, 0.4);
|
||||||
|
background: rgba(29, 155, 108, 0.1);
|
||||||
|
}
|
||||||
|
.ordered {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
.empty { padding: 12px; border: 1px dashed var(--line); border-radius: 14px; color: var(--muted); text-align: center; }
|
.empty { padding: 12px; border: 1px dashed var(--line); border-radius: 14px; color: var(--muted); text-align: center; }
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.layout { grid-template-columns: 1fr; }
|
.layout { grid-template-columns: 1fr; }
|
||||||
.sidebar { position: static; }
|
.sidebar { position: static; }
|
||||||
.hero-grid, .detail-grid { grid-template-columns: 1fr; }
|
.hero-grid, .detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.mini-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.shell { padding: 14px; }
|
.shell { padding: 14px; }
|
||||||
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
|
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
|
||||||
.stats { grid-template-columns: 1fr; }
|
.stats { grid-template-columns: 1fr; }
|
||||||
|
.mini-stats { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -270,6 +310,8 @@
|
|||||||
<div class="stat"><span>Lessons</span><strong id="lessonCount">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>Exercises</span><strong id="exerciseCount">0</strong></div>
|
||||||
<div class="stat"><span>Commands</span><strong id="commandCount">0</strong></div>
|
<div class="stat"><span>Commands</span><strong id="commandCount">0</strong></div>
|
||||||
|
<div class="stat"><span>Mastered</span><strong id="masteredCount">0</strong></div>
|
||||||
|
<div class="stat"><span>Completion</span><strong id="completionRate">0%</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chip-list" id="commandTags"></div>
|
<div class="chip-list" id="commandTags"></div>
|
||||||
<p class="muted" id="metaLine" style="margin-top: 12px;">Version --</p>
|
<p class="muted" id="metaLine" style="margin-top: 12px;">Version --</p>
|
||||||
@@ -301,6 +343,7 @@
|
|||||||
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p>
|
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button class="btn-soft" type="button" id="masteryBtn">Mark mastered</button>
|
||||||
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
|
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
|
||||||
<button class="btn" type="button" id="nextBtn">Next</button>
|
<button class="btn" type="button" id="nextBtn">Next</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,6 +363,31 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mastery-note" id="masteryNote">Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail card">
|
||||||
|
<div class="eyebrow">Learning cockpit</div>
|
||||||
|
<h2 style="margin: 8px 0 0;">Visual study map for the current lesson</h2>
|
||||||
|
<p class="muted">These cards help you move from memorizing a command to understanding the workflow around it.</p>
|
||||||
|
<div class="detail-grid" style="margin-top: 16px;">
|
||||||
|
<article class="panel">
|
||||||
|
<h3>Lesson radar</h3>
|
||||||
|
<div class="mini-stats" id="lessonRadar"></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel">
|
||||||
|
<h3>Experiment ladder</h3>
|
||||||
|
<ol class="ordered" id="experimentList"></ol>
|
||||||
|
</article>
|
||||||
|
<article class="panel">
|
||||||
|
<h3>Observation checklist</h3>
|
||||||
|
<ul id="observationList"></ul>
|
||||||
|
</article>
|
||||||
|
<article class="panel">
|
||||||
|
<h3>Related commands</h3>
|
||||||
|
<div class="chip-list" id="relatedCommandList"></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="detail card">
|
<section class="detail card">
|
||||||
@@ -371,7 +439,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const state = { overview: null, lesson: null, theme: localStorage.getItem('linux_lab_theme') || 'light' };
|
const state = {
|
||||||
|
overview: null,
|
||||||
|
lesson: null,
|
||||||
|
theme: localStorage.getItem('linux_lab_theme') || 'light',
|
||||||
|
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}')
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
setTheme(state.theme);
|
setTheme(state.theme);
|
||||||
@@ -385,9 +458,37 @@
|
|||||||
document.getElementById('nextBtn').addEventListener('click', () => {
|
document.getElementById('nextBtn').addEventListener('click', () => {
|
||||||
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
|
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
|
||||||
});
|
});
|
||||||
|
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
|
||||||
await loadOverview();
|
await loadOverview();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function saveProgress() {
|
||||||
|
localStorage.setItem('linux_lab_progress', JSON.stringify(state.progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
function masteredCount() {
|
||||||
|
return Object.values(state.progress).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMastered(lessonId) {
|
||||||
|
return Boolean(state.progress[lessonId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionRate() {
|
||||||
|
const total = state.overview?.meta?.lesson_count || 0;
|
||||||
|
if (!total) return 0;
|
||||||
|
return Math.round((masteredCount() / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMastery() {
|
||||||
|
if (!state.lesson?.id) return;
|
||||||
|
state.progress[state.lesson.id] = !state.progress[state.lesson.id];
|
||||||
|
saveProgress();
|
||||||
|
renderOverview();
|
||||||
|
renderModules();
|
||||||
|
renderLesson();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
const response = await fetch('/api/overview');
|
const response = await fetch('/api/overview');
|
||||||
state.overview = await response.json();
|
state.overview = await response.json();
|
||||||
@@ -404,6 +505,8 @@
|
|||||||
document.getElementById('lessonCount').textContent = meta.lesson_count || 0;
|
document.getElementById('lessonCount').textContent = meta.lesson_count || 0;
|
||||||
document.getElementById('exerciseCount').textContent = meta.exercise_count || 0;
|
document.getElementById('exerciseCount').textContent = meta.exercise_count || 0;
|
||||||
document.getElementById('commandCount').textContent = meta.command_count || 0;
|
document.getElementById('commandCount').textContent = meta.command_count || 0;
|
||||||
|
document.getElementById('masteredCount').textContent = masteredCount();
|
||||||
|
document.getElementById('completionRate').textContent = completionRate() + '%';
|
||||||
document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`;
|
document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`;
|
||||||
renderRuntime(state.overview.runtime || {});
|
renderRuntime(state.overview.runtime || {});
|
||||||
renderCommandTags(state.overview.commands || []);
|
renderCommandTags(state.overview.commands || []);
|
||||||
@@ -437,8 +540,8 @@
|
|||||||
<p>${escapeHtml(module.display_summary)}</p>
|
<p>${escapeHtml(module.display_summary)}</p>
|
||||||
<div class="lesson-list">
|
<div class="lesson-list">
|
||||||
${(module.lessons || []).map((lesson) => `
|
${(module.lessons || []).map((lesson) => `
|
||||||
<button class="lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''}" type="button" onclick="openLesson('${lesson.id}')">
|
<button class="lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''} ${isMastered(lesson.id) ? 'done' : ''}" type="button" onclick="openLesson('${lesson.id}')">
|
||||||
<strong>${escapeHtml(lesson.display_title)}</strong>
|
<strong>${isMastered(lesson.id) ? 'Completed - ' : ''}${escapeHtml(lesson.display_title)}</strong>
|
||||||
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || 'mixed commands')} | ${lesson.exercise_count || 0} exercises</span>
|
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || 'mixed commands')} | ${lesson.exercise_count || 0} exercises</span>
|
||||||
</button>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -470,9 +573,14 @@
|
|||||||
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
|
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
|
||||||
document.getElementById('classicView').textContent = lesson.classic_view || '';
|
document.getElementById('classicView').textContent = lesson.classic_view || '';
|
||||||
document.getElementById('afterClass').textContent = lesson.after_class || '';
|
document.getElementById('afterClass').textContent = lesson.after_class || '';
|
||||||
|
document.getElementById('masteryBtn').textContent = isMastered(lesson.id) ? 'Mark for review' : 'Mark mastered';
|
||||||
|
document.getElementById('masteryNote').textContent = isMastered(lesson.id)
|
||||||
|
? 'This lesson is marked as mastered. Revisit it if you cannot explain the command chain from memory.'
|
||||||
|
: 'Mark a lesson as mastered after you can explain the command, predict the output, and choose the next troubleshooting step.';
|
||||||
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
|
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
|
||||||
document.getElementById('nextBtn').disabled = !lesson.next_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('');
|
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || 'mixed commands']).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
|
||||||
|
renderLessonCockpit(lesson);
|
||||||
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
|
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
|
||||||
renderList('scenarioList', lesson.scenarios, 'No scenario notes yet.');
|
renderList('scenarioList', lesson.scenarios, 'No scenario notes yet.');
|
||||||
renderList('pitfallList', lesson.pitfalls, 'No pitfall notes yet.');
|
renderList('pitfallList', lesson.pitfalls, 'No pitfall notes yet.');
|
||||||
@@ -483,6 +591,40 @@
|
|||||||
renderExercises(lesson.exercises || []);
|
renderExercises(lesson.exercises || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLessonCockpit(lesson) {
|
||||||
|
document.getElementById('lessonRadar').innerHTML = [
|
||||||
|
{ label: 'Commands', value: lesson.command_tokens?.length || 1 },
|
||||||
|
{ label: 'Exercises', value: lesson.exercise_count || 0 },
|
||||||
|
{ label: 'Pitfalls', value: lesson.pitfalls?.length || 0 },
|
||||||
|
{ label: 'Related', value: lesson.related_commands?.length || 0 }
|
||||||
|
].map((item) => `
|
||||||
|
<div class="mini-stat">
|
||||||
|
<span>${escapeHtml(item.label)}</span>
|
||||||
|
<strong>${item.value}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const experimentSteps = [
|
||||||
|
`Confirm the goal: ${lesson.display_goal || 'Understand the command objective.'}`,
|
||||||
|
`Run ${lesson.command || 'the main command'} and inspect the immediate output.`,
|
||||||
|
`Compare the output with one example or scenario from this lesson.`,
|
||||||
|
`Choose the next troubleshooting step based on what the command revealed.`
|
||||||
|
];
|
||||||
|
document.getElementById('experimentList').innerHTML = experimentSteps.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
|
||||||
|
|
||||||
|
const observationItems = [
|
||||||
|
`Before running: confirm the current context and target path.`,
|
||||||
|
`During output review: look for the field or line that answers the lesson goal.`,
|
||||||
|
`After running: explain what should happen next in a real operations flow.`
|
||||||
|
];
|
||||||
|
document.getElementById('observationList').innerHTML = observationItems.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
|
||||||
|
|
||||||
|
const related = lesson.related_commands || [];
|
||||||
|
document.getElementById('relatedCommandList').innerHTML = related.length
|
||||||
|
? related.map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('')
|
||||||
|
: '<span class="chip">No related commands yet</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderList(id, items, emptyText) {
|
function renderList(id, items, emptyText) {
|
||||||
const target = document.getElementById(id);
|
const target = document.getElementById(id);
|
||||||
target.innerHTML = (items && items.length) ? items.map((item) => `<li>${escapeHtml(item)}</li>`).join('') : `<li>${escapeHtml(emptyText)}</li>`;
|
target.innerHTML = (items && items.length) ? items.map((item) => `<li>${escapeHtml(item)}</li>`).join('') : `<li>${escapeHtml(emptyText)}</li>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user