Add Doris Schoolhouse and clean pending homelab changes

This commit is contained in:
Fizzlepoof
2026-05-15 21:58:42 +00:00
parent 3aad8d037b
commit 383cd41ae7
60 changed files with 3145 additions and 20 deletions

View File

@@ -0,0 +1,60 @@
:root {
color-scheme: dark;
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--line: #334155;
--text: #e5e7eb;
--muted: #94a3b8;
--accent: #38bdf8;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: linear-gradient(180deg, #020617, var(--bg));
color: var(--text);
}
.site-header, .page-shell { max-width: 1200px; margin: 0 auto; padding: 24px; }
.site-header { display: flex; justify-content: space-between; gap: 24px; align-items: end; }
.site-header p { color: var(--muted); margin: 6px 0 0; }
nav { display: flex; gap: 16px; flex-wrap: wrap; }
nav a { color: var(--accent); text-decoration: none; }
.hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.card {
background: rgba(17, 24, 39, 0.92);
border: 1px solid var(--line);
border-radius: 16px;
padding: 20px;
}
.form-card { max-width: 760px; }
.stack { display: grid; gap: 14px; }
label { display: grid; gap: 8px; color: var(--muted); }
input, textarea, button {
font: inherit;
border-radius: 10px;
border: 1px solid var(--line);
padding: 12px 14px;
}
input, textarea { background: var(--panel-2); color: var(--text); }
button {
background: var(--accent);
color: #082f49;
font-weight: 700;
cursor: pointer;
}
.muted { color: var(--muted); }
.stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin: 12px 0 16px; }
.stat-grid div { background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; padding: 12px; display: grid; gap: 8px; }
.stat-grid span { font-size: 1.5rem; font-weight: 700; color: var(--text); }
.three-up { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-top: 20px; }
.list-block { display: grid; gap: 10px; }
.list-item { padding: 10px 12px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; }
.list-item strong { display: block; margin-bottom: 6px; }
.list-item small { color: var(--muted); display: block; }

View File

@@ -0,0 +1,127 @@
async function fetchJson(url, options) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`Fetch failed: ${url}`);
return await res.json();
}
function option(value, label) {
const el = document.createElement('option');
el.value = value;
el.textContent = label;
return el;
}
function renderList(targetId, items, formatter) {
const target = document.getElementById(targetId);
if (!target) return;
if (!items.length) {
target.textContent = 'Nothing here yet.';
target.classList.add('muted');
return;
}
target.classList.remove('muted');
target.innerHTML = '';
for (const item of items) {
const wrapper = document.createElement('div');
wrapper.className = 'list-item';
wrapper.innerHTML = formatter(item);
target.appendChild(wrapper);
}
}
async function populateCourses() {
const data = await fetchJson('/api/d2l/courses');
const courseSelect = document.getElementById('class-select');
const courseNameInput = document.getElementById('class-name-input');
const recordingSelect = document.getElementById('recording-class-select');
const recordingNameInput = document.getElementById('recording-class-name-input');
const courses = data.items || [];
const byId = new Map(courses.map((course) => [String(course.d2l_course_id || course.id), course]));
if (courseSelect) {
courseSelect.innerHTML = '';
courseSelect.appendChild(option('', 'Choose a class'));
for (const course of courses) {
courseSelect.appendChild(option(course.d2l_course_id || course.id, `${course.name}${course.finalSummary ? ' — ' + course.finalSummary : ''}`));
}
courseSelect.addEventListener('change', async (event) => {
const courseId = event.target.value;
const course = byId.get(String(courseId));
if (courseNameInput) courseNameInput.value = course?.name || '';
const assignmentSelect = document.getElementById('assignment-select');
assignmentSelect.innerHTML = '';
if (!courseId) {
assignmentSelect.appendChild(option('', 'Pick a class first'));
return;
}
assignmentSelect.appendChild(option('', 'Loading assignments…'));
const assignmentData = await fetchJson(`/api/d2l/assignments?course_id=${encodeURIComponent(courseId)}`);
assignmentSelect.innerHTML = '';
assignmentSelect.appendChild(option('', 'Choose an assignment'));
for (const assignment of assignmentData.items || []) {
assignmentSelect.appendChild(option(assignment.title, assignment.title));
}
});
}
if (recordingSelect) {
recordingSelect.innerHTML = '';
recordingSelect.appendChild(option('', 'Choose a class'));
for (const course of courses) {
recordingSelect.appendChild(option(course.d2l_course_id || course.id, course.name));
}
recordingSelect.addEventListener('change', (event) => {
const course = byId.get(String(event.target.value));
if (recordingNameInput) recordingNameInput.value = course?.name || '';
});
}
}
async function loadDashboard() {
const data = await fetchJson('/api/dashboard/summary');
const counts = data.counts || {};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
setText('stat-courses', counts.courses ?? '0');
setText('stat-assignments', counts.assignments ?? '0');
setText('stat-submissions', counts.submissions ?? '0');
setText('stat-recordings', counts.recordings ?? '0');
setText('backend-note', `Storage backend: ${data.backend}`);
if (data.latest_sync) {
setText('sync-status', `Last sync: ${data.latest_sync.completed_at || 'unknown'} · ${data.latest_sync.assignment_count || 0} assignment rows`);
}
renderList('recent-assignments', data.recent_assignments || [], (item) => `<strong>${item.title || 'Untitled'}</strong><small>${item.course_name || ''}</small><small>Status: ${item.status || 'assigned'} · Grade: ${item.grade || '—'}</small>`);
renderList('recent-submissions', data.recent_submissions || [], (item) => `<strong>${item.assignment_name || item.original_filename || 'Submission'}</strong><small>${item.class_name || ''}</small><small>Status: ${item.status || 'unknown'} · File: ${item.original_filename || '—'}</small>`);
renderList('recent-recordings', data.recent_recordings || [], (item) => `<strong>${item.original_filename || 'Recording'}</strong><small>${item.session_date || ''}</small><small>Transcription: ${item.transcription_status || '—'} · Summary: ${item.summary_status || '—'}</small>`);
}
async function wireSyncButton() {
const button = document.getElementById('sync-button');
if (!button) return;
button.addEventListener('click', async () => {
button.disabled = true;
button.textContent = 'Refreshing…';
try {
const result = await fetchJson('/api/d2l/sync', { method: 'POST' });
const el = document.getElementById('sync-status');
if (el) el.textContent = `Last sync: just now · ${result.assignment_count} assignment rows · backend ${result.backend}`;
await loadDashboard();
} catch (error) {
const el = document.getElementById('sync-status');
if (el) el.textContent = `Sync failed: ${error}`;
} finally {
button.disabled = false;
button.textContent = 'Refresh from D2L';
}
});
}
populateCourses().catch((error) => {
console.error('Failed to populate D2L selectors', error);
});
loadDashboard().catch((error) => {
console.error('Failed to load dashboard summary', error);
});
wireSyncButton();