Redesign course unit list into collapsible cards on student and admin views
Student view auto-opens the unit whose item due-dates span today (falls back to most-recent past unit, earliest future unit, then first with incomplete work). Admin view starts all units expanded. Both share the same card design: unit-number badge, name, progress/count indicator, animated chevron toggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac7b86b470
commit
723bbb93c8
3 files changed
+189
-40
No files matched your search
+32
-15
@@ -235,26 +235,43 @@ async function openCourseDetail(id) {
|
||||
|
||||
$('#cd-units').innerHTML = course.units
|
||||
.map(
|
||||
(u) => `<div class="unit-block">
|
||||
<div class="unit-header">
|
||||
<h3>${esc(u.name)}</h3>
|
||||
<button class="danger small" data-del-unit="${u.id}">Delete unit</button>
|
||||
(u, uIdx) => `<div class="unit-block open">
|
||||
<div class="unit-toggle">
|
||||
<div class="unit-toggle-left">
|
||||
<span class="unit-num-badge">Unit ${uIdx + 1}</span>
|
||||
<span class="unit-title-text">${esc(u.name)}</span>
|
||||
</div>
|
||||
<div class="unit-toggle-right">
|
||||
<span class="unit-item-count">${u.items.length} item${u.items.length !== 1 ? 's' : ''}</span>
|
||||
<span class="unit-chevron" aria-hidden="true">▾</span>
|
||||
<button class="danger small" data-del-unit="${u.id}">Delete unit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unit-items-list">
|
||||
${u.items
|
||||
.map(
|
||||
(it) => `<div class="item-row">
|
||||
<span>${TYPE_ICON[it.type]}</span>
|
||||
<span class="grow">${esc(it.title)}</span>
|
||||
<span>${TYPE_LABEL[it.type]}</span>
|
||||
<button data-edit-item="${it.id}" data-unit-id="${u.id}">Edit</button>
|
||||
</div>`
|
||||
)
|
||||
.join('')}
|
||||
<button class="secondary small" data-add-item="${u.id}">+ Add item to this unit</button>
|
||||
</div>
|
||||
${u.items
|
||||
.map(
|
||||
(it) => `<div class="item-row">
|
||||
<span>${TYPE_ICON[it.type]}</span>
|
||||
<span class="grow">${esc(it.title)}</span>
|
||||
<span>${TYPE_LABEL[it.type]}</span>
|
||||
<button data-edit-item="${it.id}" data-unit-id="${u.id}">Edit</button>
|
||||
</div>`
|
||||
)
|
||||
.join('')}
|
||||
<button class="secondary small" data-add-item="${u.id}">+ Add item to this unit</button>
|
||||
</div>`
|
||||
)
|
||||
.join('') || `<p class="hint">No units yet — add one below.</p>`;
|
||||
|
||||
document.querySelectorAll('#cd-units .unit-toggle').forEach((hdr) => {
|
||||
hdr.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const block = hdr.closest('.unit-block');
|
||||
block.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-del-unit]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this unit and everything in it?')) return;
|
||||
|
||||
+71
-23
@@ -508,34 +508,82 @@ function printSpellingWorksheet(name, words) {
|
||||
|
||||
// ---------- course detail ----------
|
||||
|
||||
function activeUnitIndex(units) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const dated = units.map((u, i) => {
|
||||
const dates = u.items.map((it) => it.due_date).filter(Boolean).sort();
|
||||
return { i, earliest: dates[0] || null, latest: dates[dates.length - 1] || null };
|
||||
}).filter((x) => x.earliest);
|
||||
|
||||
if (dated.length) {
|
||||
const spanning = dated.find((x) => x.earliest <= today && x.latest >= today);
|
||||
if (spanning) return spanning.i;
|
||||
const past = dated.filter((x) => x.latest < today).sort((a, b) => b.latest.localeCompare(a.latest));
|
||||
if (past.length) return past[0].i;
|
||||
const future = dated.filter((x) => x.earliest > today).sort((a, b) => a.earliest.localeCompare(b.earliest));
|
||||
if (future.length) return future[0].i;
|
||||
}
|
||||
const fallback = units.findIndex((u) => u.items.some((it) => it.status === 'not_started' && !it.locked));
|
||||
return fallback >= 0 ? fallback : 0;
|
||||
}
|
||||
|
||||
async function openCourse(courseId) {
|
||||
const course = await api(`/api/courses/${courseId}/detail?studentId=${currentStudent.id}`);
|
||||
$('#course-title').textContent = course.name;
|
||||
const activeIdx = activeUnitIndex(course.units);
|
||||
$('#course-units').innerHTML = course.units
|
||||
.map(
|
||||
(u) => `<div class="unit-block">
|
||||
<h2>${esc(u.name)}</h2>
|
||||
${u.items
|
||||
.map((it) => {
|
||||
if (it.locked) {
|
||||
return `<div class="today-row locked">
|
||||
<span class="row-check">🔒</span>
|
||||
<span class="row-title">${esc(it.title)}<small>${TYPE_LABEL[it.type]}</small></span>
|
||||
<span class="row-badge hint">Complete previous item first</span>
|
||||
</div>`;
|
||||
}
|
||||
const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started');
|
||||
const due = it.due_date ? `<small class="due-date">Due ${it.due_date}</small>` : '';
|
||||
return `<button class="today-row" data-item-id="${it.id}" data-item-type="${it.type}">
|
||||
<span class="row-check">${it.status === 'not_started' ? TYPE_ICON[it.type] : '✅'}</span>
|
||||
<span class="row-title">${esc(it.title)}${due}<small>${TYPE_LABEL[it.type]}</small></span>
|
||||
<span class="row-badge">${badge}</span>
|
||||
</button>`;
|
||||
})
|
||||
.join('')}
|
||||
</div>`
|
||||
)
|
||||
.map((u, uIdx) => {
|
||||
const total = u.items.length;
|
||||
const done = u.items.filter((it) => it.status !== 'not_started').length;
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
const isOpen = uIdx === activeIdx;
|
||||
|
||||
const itemsHtml = u.items
|
||||
.map((it) => {
|
||||
if (it.locked) {
|
||||
return `<div class="today-row locked">
|
||||
<span class="row-check">🔒</span>
|
||||
<span class="row-title">${esc(it.title)}<small>${TYPE_LABEL[it.type]}</small></span>
|
||||
<span class="row-badge hint">Complete previous item first</span>
|
||||
</div>`;
|
||||
}
|
||||
const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started');
|
||||
const due = it.due_date ? `<small class="due-date">Due ${it.due_date}</small>` : '';
|
||||
return `<button class="today-row" data-item-id="${it.id}" data-item-type="${it.type}">
|
||||
<span class="row-check">${it.status === 'not_started' ? TYPE_ICON[it.type] : '✅'}</span>
|
||||
<span class="row-title">${esc(it.title)}${due}<small>${TYPE_LABEL[it.type]}</small></span>
|
||||
<span class="row-badge">${badge}</span>
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<div class="unit-block${isOpen ? ' open' : ''}">
|
||||
<button class="unit-toggle" type="button" aria-expanded="${isOpen}">
|
||||
<div class="unit-toggle-left">
|
||||
<span class="unit-num-badge">Unit ${uIdx + 1}</span>
|
||||
<span class="unit-title-text">${esc(u.name)}</span>
|
||||
</div>
|
||||
<div class="unit-toggle-right">
|
||||
<span class="unit-done-count">${done}/${total}</span>
|
||||
<div class="progress-track small unit-mini-progress">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="unit-chevron" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="unit-items-list">${itemsHtml}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
document.querySelectorAll('#course-units .unit-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const block = btn.closest('.unit-block');
|
||||
const nowOpen = block.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', String(nowOpen));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('#course-units .today-row:not(.locked)').forEach((row) =>
|
||||
row.addEventListener('click', () => {
|
||||
backTarget = () => openCourse(courseId);
|
||||
|
||||
+86
-2
@@ -283,8 +283,92 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
|
||||
.course-pct { font-size: 0.85rem; color: var(--muted); }
|
||||
.progress-track.small { height: 8px; margin: 0.2rem 0; }
|
||||
|
||||
.unit-block { margin-bottom: 1.5rem; }
|
||||
.unit-block h2 { font-size: 1.15rem; margin-bottom: 0.5rem; }
|
||||
/* student course unit cards */
|
||||
#course-units .unit-block {
|
||||
background: var(--card); border-radius: 16px; margin: 0.75rem 0;
|
||||
box-shadow: 0 2px 10px rgba(45, 49, 66, 0.08); overflow: hidden;
|
||||
}
|
||||
#course-units .unit-toggle {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; background: none; border: none; padding: 1rem 1.1rem;
|
||||
cursor: pointer; font-family: inherit; text-align: left; gap: 0.75rem;
|
||||
border-bottom: 2px solid transparent; transition: border-color 0.15s;
|
||||
}
|
||||
#course-units .unit-block.open .unit-toggle { border-bottom-color: var(--accent); }
|
||||
#course-units .unit-toggle-left {
|
||||
display: flex; align-items: center; gap: 0.6rem; flex: 1; min-width: 0;
|
||||
}
|
||||
#course-units .unit-num-badge {
|
||||
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; background: var(--accent); color: #fff;
|
||||
padding: 0.2em 0.55em; border-radius: 6px; flex-shrink: 0;
|
||||
}
|
||||
#course-units .unit-title-text {
|
||||
font-size: 1.05rem; font-weight: 700; color: var(--ink);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
#course-units .unit-toggle-right {
|
||||
display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;
|
||||
}
|
||||
#course-units .unit-done-count {
|
||||
font-size: 0.82rem; font-weight: 700; color: var(--muted); white-space: nowrap;
|
||||
}
|
||||
#course-units .unit-mini-progress { width: 56px; height: 6px; margin: 0; }
|
||||
#course-units .unit-chevron {
|
||||
font-size: 1.15rem; color: var(--muted); display: inline-block;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
#course-units .unit-block:not(.open) .unit-chevron { transform: rotate(-90deg); }
|
||||
#course-units .unit-items-list {
|
||||
max-height: 0; overflow: hidden;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
padding: 0 0.85rem;
|
||||
}
|
||||
#course-units .unit-block.open .unit-items-list {
|
||||
max-height: 4000px; padding: 0.1rem 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
/* admin course unit cards */
|
||||
#cd-units .unit-block {
|
||||
background: var(--card); border-radius: 16px; margin: 0.75rem 0; padding: 0;
|
||||
box-shadow: 0 2px 10px rgba(45, 49, 66, 0.08); overflow: hidden;
|
||||
}
|
||||
#cd-units .unit-toggle {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.85rem 1rem; cursor: pointer; gap: 0.75rem; user-select: none;
|
||||
border-bottom: 2px solid transparent; transition: border-color 0.15s;
|
||||
}
|
||||
#cd-units .unit-block.open .unit-toggle { border-bottom-color: var(--accent); }
|
||||
#cd-units .unit-toggle-left {
|
||||
display: flex; align-items: center; gap: 0.6rem; flex: 1; min-width: 0;
|
||||
}
|
||||
#cd-units .unit-num-badge {
|
||||
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; background: var(--accent); color: #fff;
|
||||
padding: 0.2em 0.55em; border-radius: 6px; flex-shrink: 0;
|
||||
}
|
||||
#cd-units .unit-title-text {
|
||||
font-size: 1rem; font-weight: 700; color: var(--ink);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
#cd-units .unit-toggle-right {
|
||||
display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0;
|
||||
}
|
||||
#cd-units .unit-item-count {
|
||||
font-size: 0.82rem; font-weight: 700; color: var(--muted); white-space: nowrap;
|
||||
}
|
||||
#cd-units .unit-chevron {
|
||||
font-size: 1.1rem; color: var(--muted); transition: transform 0.22s ease;
|
||||
}
|
||||
#cd-units .unit-block:not(.open) .unit-chevron { transform: rotate(-90deg); }
|
||||
#cd-units .unit-items-list {
|
||||
max-height: 0; overflow: hidden;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
padding: 0 0.85rem;
|
||||
}
|
||||
#cd-units .unit-block.open .unit-items-list {
|
||||
max-height: 4000px; padding: 0.5rem 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- item viewers (lesson/assignment/quiz) ---------- */
|
||||
.item-card {
|
||||
|
||||
Reference in new issue
Block a user