Add kid engagement features and planner drag-and-drop

- Streak tracker: server-side streak counting per kid, celebration overlay
  when all today's tasks are done for the first time that day
- Kid themes: 5 colour palettes (blue/green/purple/orange/pink), picked via
  colour dots on the kid home screen; parents can set it from the Kids panel
- Grading badge: Grading nav pill turns red and shows a pending count
- Print worksheets: 🖨 Worksheet button per word list in admin Spelling panel;
  kids also get "Print my words" on the Spelling tab
- Parent feedback: gold 💬 badge on Today rows with unread comments; more
  prominent callout styling inside the assignment view
- Planner drag-and-drop: tasks can be dragged between days; PATCH
  /api/schedule/:id moves the task, drop zones highlight on hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-21 16:40:07 -04:00
1 parent 39d690bf93
commit 3c22fe05f8
9 files changed
+832 -18

No files matched your search

+13 -1
View File
@@ -45,6 +45,7 @@
<select id="new-student-emoji" aria-label="Avatar">
<option>🦊</option><option>🐸</option><option>🦄</option><option>🐯</option>
<option>🐼</option><option>🦖</option><option>🐙</option><option>🦉</option>
<option>🐻</option><option>🐰</option><option>🦁</option><option>🐶</option>
</select>
<button type="submit">Add kid</button>
</form>
@@ -149,6 +150,17 @@
<p id="scan-status" class="hint"></p>
</div>
<!-- Import from Homeschool folder: visible for lesson, assignment, quiz types -->
<div id="ie-import-section" class="scan-card" hidden>
<strong>📂 Import from Homeschool folder</strong>
<div class="scan-controls">
<select id="import-file-select" style="flex:1"><option value="">— choose a file —</option></select>
<button type="button" id="import-load-btn" class="secondary">↓ Load files</button>
<button type="button" id="import-go-btn" class="secondary" disabled>📥 Import</button>
</div>
<p id="import-status" class="hint"></p>
</div>
<div id="ie-field-lesson" class="ie-field">
<label>Lesson content (text, or paste HTML — images/YouTube embeds allowed)
<textarea id="ie-body-lesson" rows="8" placeholder="Weather is what the sky and air are doing right now.&#10;&#10;&lt;iframe src=&quot;https://www.youtube.com/embed/VIDEO_ID&quot;&gt;&lt;/iframe&gt;"></textarea>
@@ -258,7 +270,7 @@
<input type="hidden" id="list-id">
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
<textarea id="list-words" rows="8"
placeholder="One word per line. Optionally add sentence and definition with | like:&#10;receive | Did you receive my letter? | to get or accept something"></textarea>
placeholder="One word per line. Paste any list from your curriculum!&#10;&#10;Add an example sentence with |&#10;receive | Did you receive my letter?&#10;&#10;Or just a plain list:&#10;because&#10;enough&#10;friend"></textarea>
<div class="row-actions">
<button type="submit">Save list</button>
<button type="button" id="list-cancel" class="secondary">Clear</button>
+183 -8
View File
@@ -52,6 +52,21 @@ async function unlock() {
$('#pin-gate').hidden = true;
$('#console').hidden = false;
showPanel('kids');
loadGradingBadge();
}
async function loadGradingBadge() {
try {
const { count } = await api('/api/grading-queue/count');
const pill = document.querySelector('.nav-pill[data-panel="grading"]');
if (count > 0) {
pill.textContent = `✅ Grading (${count})`;
pill.classList.add('grading-pending');
} else {
pill.textContent = '✅ Grading';
pill.classList.remove('grading-pending');
}
} catch {}
}
if (parentPin) unlock();
@@ -81,20 +96,41 @@ function showPanel(name) {
// Kids
// ============================================================
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a' };
async function loadKids() {
const students = await api('/api/students');
$('#student-rows').innerHTML = students.length
? students
.map(
(s) => `<div class="item-row">
<span>${esc(s.emoji)}</span>
.map((s) => {
const theme = s.theme || 'blue';
const swatches = Object.entries(THEME_COLORS)
.map(([t, c]) => `<button class="theme-swatch theme-${t} ${t === theme ? 'selected' : ''}" data-student-theme="${s.id}" data-theme="${t}" title="${t}" style="background:${c}"></button>`)
.join('');
const streak = s.streak_count >= 2 ? `<span style="color:#e8802a;font-size:.85rem">🔥 ${s.streak_count} day streak</span>` : '';
return `<div class="item-row">
<span style="font-size:1.5rem">${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
${streak}
<div class="theme-swatch-row" title="Kid's color theme">${swatches}</div>
<button class="danger" data-del-student="${s.id}">Remove</button>
</div>`
)
</div>`;
})
.join('')
: `<p class="hint">No kids yet — add one below.</p>`;
document.querySelectorAll('[data-student-theme]').forEach((btn) =>
btn.addEventListener('click', async () => {
const studentId = btn.dataset.studentTheme;
const theme = btn.dataset.theme;
await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } });
// Update selected state without full reload
document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) =>
b.classList.toggle('selected', b.dataset.theme === theme)
);
})
);
document.querySelectorAll('[data-del-student]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return;
@@ -332,6 +368,7 @@ async function openItemEditor(unitId, itemId) {
}
updateItemFieldVisibility();
resetScanSection();
resetImportSection();
showPanel('item-editor');
}
@@ -343,6 +380,7 @@ function updateItemFieldVisibility() {
$('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test';
$('#ie-field-flashcards').hidden = type !== 'flashcards';
$('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz';
$('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards';
}
$('#ie-type').addEventListener('change', updateItemFieldVisibility);
@@ -350,6 +388,12 @@ $('#ie-type').addEventListener('change', updateItemFieldVisibility);
let scanImageBase64 = null;
function resetImportSection() {
$('#import-file-select').innerHTML = '<option value="">— choose a file —</option>';
$('#import-go-btn').disabled = true;
$('#import-status').textContent = '';
}
function resetScanSection() {
scanImageBase64 = null;
$('#scan-file-input').value = '';
@@ -431,6 +475,65 @@ $('#scan-go-btn').addEventListener('click', async () => {
}
});
// ---------- import from Homeschool folder ----------
$('#import-load-btn').addEventListener('click', async () => {
$('#import-status').textContent = 'Loading files…';
$('#import-file-select').innerHTML = '<option value="">— loading… —</option>';
try {
const { files } = await api('/api/admin/homeschool-files');
if (!files.length) {
$('#import-file-select').innerHTML = '<option value="">No .docx files found</option>';
$('#import-status').textContent = '';
return;
}
$('#import-file-select').innerHTML =
'<option value="">— choose a file —</option>' +
files.map((f) => `<option value="${esc(f.path)}">${esc(f.path)}</option>`).join('');
$('#import-go-btn').disabled = false;
$('#import-status').textContent = `${files.length} file(s) found.`;
} catch (err) {
$('#import-status').textContent = `❌ ${err.message}`;
}
});
$('#import-file-select').addEventListener('change', () => {
$('#import-go-btn').disabled = !$('#import-file-select').value;
});
$('#import-go-btn').addEventListener('click', async () => {
const path = $('#import-file-select').value;
if (!path) return;
const type = $('#ie-type').value;
const mode = type === 'quiz' ? 'quiz' : type === 'lesson' ? 'lesson' : 'lesson';
$('#import-go-btn').disabled = true;
$('#import-status').textContent = '🔍 Extracting content… this may take a minute.';
try {
const result = await api('/api/admin/import-docx', { method: 'POST', body: { path, mode } });
if (!$('#ie-title').value && result.title) $('#ie-title').value = result.title;
if (mode === 'quiz' && result.questions) {
$('#ie-questions').innerHTML = '';
questionCount = 0;
const normalized = result.questions.map((q) => ({
...q,
correct_answer: q.correctAnswer ?? q.correct_answer ?? '',
}));
normalized.forEach(addQuestionRow);
$('#import-status').textContent = `✅ Imported ${result.questions.length} question(s). Review before saving.`;
} else if (result.body) {
const bodyField = type === 'assignment' ? '#ie-body-assignment' : '#ie-body-lesson';
$(bodyField).value = result.body;
$('#import-status').textContent = '✅ Content imported. Review before saving.';
} else {
$('#import-status').textContent = '⚠️ File imported but no content was found.';
}
} catch (err) {
$('#import-status').textContent = `❌ ${err.message}`;
} finally {
$('#import-go-btn').disabled = false;
}
});
function addQuestionRow(q = {}) {
const n = questionCount++;
const div = document.createElement('div');
@@ -597,12 +700,12 @@ async function renderPlanner() {
$('#planner-grid').innerHTML = days
.map((day) => {
const dayTasks = tasks.filter((t) => t.date === day.date);
return `<div class="planner-day">
return `<div class="planner-day" data-drop-date="${day.date}">
<h3>${day.name}<small>${day.date}</small></h3>
<div class="planner-tasks">
<div class="planner-tasks" data-drop-date="${day.date}">
${dayTasks
.map(
(t) => `<div class="planner-task ${t.done ? 'done' : ''}">
(t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}">
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
</div>`
@@ -615,6 +718,39 @@ async function renderPlanner() {
})
.join('');
// Drag-and-drop: tasks between days
let draggingId = null;
document.querySelectorAll('.planner-task[draggable]').forEach((el) => {
el.addEventListener('dragstart', (e) => {
draggingId = el.dataset.taskId;
el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
el.addEventListener('dragend', () => {
draggingId = null;
el.classList.remove('dragging');
document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target'));
});
});
document.querySelectorAll('.planner-tasks').forEach((col) => {
col.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
col.classList.add('drop-target');
});
col.addEventListener('dragleave', () => col.classList.remove('drop-target'));
col.addEventListener('drop', async (e) => {
e.preventDefault();
col.classList.remove('drop-target');
if (!draggingId) return;
const newDate = col.dataset.dropDate;
await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: newDate } });
renderPlanner();
});
});
document.querySelectorAll('[data-del-schedule]').forEach((btn) =>
btn.addEventListener('click', async () => {
await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' });
@@ -768,6 +904,7 @@ async function loadGrading() {
body: { score: Number(input.value), parentComment: commentInput?.value || '' },
});
loadGrading();
loadGradingBadge();
});
});
}
@@ -886,12 +1023,20 @@ function renderLists(lists) {
(l) => `<div class="item-row">
<strong class="grow">${esc(l.name)}</strong>
<span>${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>
<button data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin ? '' : `<button class="danger" data-del-list="${l.id}">Delete</button>`}
</div>`
)
.join('');
document.querySelectorAll('[data-print-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.printList}`);
printSpellingWorksheet(list.name, list.words);
})
);
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`);
@@ -966,6 +1111,36 @@ function renderResults(students) {
document.querySelectorAll('[data-print]').forEach((btn) => btn.addEventListener('click', () => printSpellingReport(btn.dataset.print)));
}
function printSpellingWorksheet(name, words) {
const rows = words
.map((w) => {
const sentence = w.sentence ? `<div style="color:#888;font-size:.85em;font-style:italic">${esc(w.sentence)}</div>` : '';
return `<tr>
<td style="font-weight:700;font-size:1.1em;padding:.6rem .75rem">${esc(w.word)}${sentence}</td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
</tr>`;
})
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling: ${esc(name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 680px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: .4rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
td { padding: .55rem .75rem; }
th { text-align: left; color: #555; font-size: .85rem; font-weight: 600; padding: .4rem .75rem; }
</style></head><body>
<h1>✏️ Spelling: ${esc(name)}</h1>
<p style="color:#555;font-size:.9em">Write each word twice — once to practice, once from memory.</p>
<table>
<tr><th>Word</th><th>Practice</th><th>From memory</th></tr>
${rows}
</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
async function printSpellingReport(testId) {
const r = await api(`/api/test-report/${testId}`);
const rows = r.answers
+135 -3
View File
@@ -183,16 +183,41 @@ function diffLetters(typed, correct) {
return { typedHtml: typedParts.join(''), correctHtml: correctParts.join('') };
}
// ---------- themes ----------
const THEMES = {
blue: {},
green: { '--accent': '#2e9e5b', '--accent-dark': '#247a47', '--bg': '#f0faf4' },
purple: { '--accent': '#7c5cbf', '--accent-dark': '#6245a0', '--bg': '#f8f4ff' },
orange: { '--accent': '#e8802a', '--accent-dark': '#c96a1a', '--bg': '#fff8f0' },
pink: { '--accent': '#d45d8a', '--accent-dark': '#b4487a', '--bg': '#fff0f5' },
};
const THEME_NAMES = Object.keys(THEMES);
const DEFAULT_VARS = { '--accent': '#4f86f7', '--accent-dark': '#3a6fd8', '--bg': '#fdf6e3' };
function applyTheme(theme) {
const vars = THEMES[theme] || {};
for (const [k, v] of Object.entries(DEFAULT_VARS))
document.documentElement.style.setProperty(k, vars[k] || v);
document.body.className = theme && theme !== 'blue' ? `theme-${theme}` : '';
}
function resetTheme() {
for (const k of Object.keys(DEFAULT_VARS)) document.documentElement.style.removeProperty(k);
document.body.className = '';
}
// ---------- home (kid picker) ----------
let currentStudent = null;
async function loadHome() {
resetTheme();
const students = await api('/api/students');
$('#no-kids').hidden = students.length > 0;
$('#kid-cards').innerHTML = students
.map(
(s) => `<button class="kid-card" data-id="${s.id}">
(s) => `<button class="kid-card" data-id="${s.id}" data-theme="${esc(s.theme || 'blue')}">
<span class="avatar">${esc(s.emoji)}</span>${esc(s.name)}</button>`
)
.join('');
@@ -219,16 +244,79 @@ async function openKid(id, tab = 'today') {
api(`/api/courses/mine/${id}`),
]);
currentStudent = state.student;
applyTheme(currentStudent.theme || 'blue');
updateThemeDots(currentStudent.theme || 'blue');
$('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`;
// Show streak pill if kid has a streak
const existingPill = document.querySelector('.streak-pill');
if (existingPill) existingPill.remove();
if (state.student.streak_count >= 2) {
const pill = document.createElement('div');
pill.className = 'streak-pill';
pill.textContent = `🔥 ${state.student.streak_count} day streak!`;
$('#kid-greeting').insertAdjacentElement('afterend', pill);
}
renderToday(agenda.tasks);
renderCourseCards(courses);
renderSpellingTab(state);
show('kid');
switchTab(tab);
// Celebrate if all today's tasks are done
if (agenda.tasks.length > 0) {
const allDone = agenda.tasks.every((t) =>
t.done || t.offlineStatus === 'done' || t.subStatus === 'graded' || t.subStatus === 'done'
);
if (allDone) {
const result = await api(`/api/students/${id}/complete-day`, { method: 'POST' });
if (!result.alreadyCounted) showCelebration(result);
}
}
}
function updateThemeDots(active) {
document.querySelectorAll('.theme-dot').forEach((btn) =>
btn.classList.toggle('active', btn.dataset.theme === active)
);
}
function showCelebration({ streak, best }) {
const isRecord = streak > 1 && streak === best;
let streakHtml = '';
if (streak >= 2) {
streakHtml = `<div class="streak-badge">🔥 ${streak} days in a row!</div>
${isRecord ? `<div class="best-streak-label">🏆 New record!</div>` : `<div class="streak-label">Best: ${best} days</div>`}`;
} else {
streakHtml = `<div class="streak-label" style="margin-bottom:1rem">Keep it up — come back tomorrow for a streak!</div>`;
}
$('#streak-display').innerHTML = streakHtml;
$('#celebration-overlay').hidden = false;
}
$('#celebration-close').addEventListener('click', () => {
$('#celebration-overlay').hidden = true;
});
// Theme picker toggle
$('#theme-picker-btn').addEventListener('click', () => {
$('#theme-dots').hidden = !$('#theme-dots').hidden;
});
document.querySelectorAll('.theme-dot').forEach((btn) =>
btn.addEventListener('click', async () => {
const theme = btn.dataset.theme;
applyTheme(theme);
updateThemeDots(theme);
currentStudent.theme = theme;
await api(`/api/students/${currentStudent.id}/theme`, { method: 'PATCH', body: { theme } });
$('#theme-dots').hidden = true;
})
);
const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
function renderToday(tasks) {
@@ -263,9 +351,10 @@ function renderToday(tasks) {
</div>`;
}
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done);
const feedbackBadge = t.parentComment ? `<span class="feedback-badge">💬 feedback</span>` : '';
return `<button class="today-row" data-item-id="${t.itemId}" data-item-type="${t.type}">
<span class="row-check">${t.done ? '✅' : TYPE_ICON[t.type]}</span>
<span class="row-title">${esc(t.itemTitle)}<small>${esc(t.courseName)}</small></span>
<span class="row-title">${esc(t.itemTitle)}${feedbackBadge}<small>${esc(t.courseName)}</small></span>
<span class="row-badge">${badge}</span>
</button>`;
})
@@ -341,6 +430,9 @@ function renderCourseCards(courses) {
);
}
let currentSpellingListId = null;
let currentSpellingListName = '';
function renderSpellingTab(state) {
let html;
if (state.assignment) {
@@ -348,14 +440,54 @@ function renderSpellingTab(state) {
html = `<strong>This week: ${esc(state.assignment.name)}</strong>
<div class="mastery-dots">${'🌟'.repeat(mastered)}${'⚪'.repeat(Math.max(0, total - mastered))}</div>
<div>${mastered} of ${total} words mastered</div>`;
currentSpellingListId = state.assignment.id;
currentSpellingListName = state.assignment.name;
} else {
html = `No list assigned yet — ask a parent to pick one!`;
currentSpellingListId = null;
}
if (state.dueReviews > 0) {
html += `<div>🔁 ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review</div>`;
}
$('#kid-week').innerHTML = html;
$('#btn-test').disabled = !state.assignment;
$('#btn-print-list').disabled = !state.assignment;
}
$('#btn-print-list').addEventListener('click', async () => {
if (!currentSpellingListId) return;
const list = await api(`/api/lists/${currentSpellingListId}`);
printSpellingWorksheet(list.name, list.words);
});
function printSpellingWorksheet(name, words) {
const rows = words
.map((w) => {
const sentence = w.sentence ? `<div style="color:#888;font-size:.85em;font-style:italic">${esc(w.sentence)}</div>` : '';
return `<tr>
<td style="font-weight:700;font-size:1.1em;padding:.6rem .75rem">${esc(w.word)}${sentence}</td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
</tr>`;
})
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling: ${esc(name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 680px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: .4rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
td { padding: .55rem .75rem; }
th { text-align: left; color: #555; font-size: .85rem; font-weight: 600; padding: .4rem .75rem; }
</style></head><body>
<h1>✏️ Spelling: ${esc(name)}</h1>
<p style="color:#555;font-size:.9em">Write each word twice — once to practice, once from memory.</p>
<table>
<tr><th>Word</th><th>Practice</th><th>From memory</th></tr>
${rows}
</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// ---------- course detail ----------
@@ -450,7 +582,7 @@ async function openAssignment(itemId) {
// Show parent comment if graded
if (item.submission?.parent_comment) {
commentEl.hidden = false;
commentEl.innerHTML = `<strong>Parent feedback:</strong> ${esc(item.submission.parent_comment)}`;
commentEl.innerHTML = `<strong>💬 Feedback from your parent:</strong><br>${esc(item.submission.parent_comment)}`;
} else {
commentEl.hidden = true;
}
+22 -1
View File
@@ -19,8 +19,18 @@
<!-- Kid home: Today's work + nav -->
<section id="view-kid" class="view" hidden>
<button class="back-btn" data-nav="home">← Switch</button>
<div class="kid-topbar">
<button class="back-btn" data-nav="home">← Switch</button>
<button id="theme-picker-btn" class="theme-picker-btn" aria-label="Pick my color">🎨</button>
</div>
<h1 id="kid-greeting"></h1>
<div id="theme-dots" class="theme-dots" hidden>
<button class="theme-dot theme-blue" data-theme="blue" title="Blue"></button>
<button class="theme-dot theme-green" data-theme="green" title="Green"></button>
<button class="theme-dot theme-purple" data-theme="purple" title="Purple"></button>
<button class="theme-dot theme-orange" data-theme="orange" title="Orange"></button>
<button class="theme-dot theme-pink" data-theme="pink" title="Pink"></button>
</div>
<div class="kid-nav">
<button class="nav-pill active" data-tab="today">📋 Today</button>
@@ -43,10 +53,21 @@
<div class="big-actions">
<button id="btn-practice" class="big-btn practice">✏️ Practice</button>
<button id="btn-test" class="big-btn test">⭐ Friday Test</button>
<button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button>
</div>
</div>
</section>
<!-- All-done celebration overlay -->
<div id="celebration-overlay" hidden>
<div id="celebration-content">
<div class="celebration-emoji">🎉</div>
<h2>All done for today!</h2>
<div id="streak-display"></div>
<button id="celebration-close" class="check-btn">High five! ✋</button>
</div>
</div>
<!-- Course detail: units + items -->
<section id="view-course" class="view" hidden>
<button class="back-btn" data-back="course">← Back</button>
+102
View File
@@ -380,6 +380,14 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
background: #f7f2e3; border-radius: 8px; padding: 0.4rem 0.6rem; font-size: 0.85rem;
}
.planner-task.done { opacity: 0.55; text-decoration: line-through; }
.planner-task[draggable] { cursor: grab; }
.planner-task[draggable]:active { cursor: grabbing; }
.planner-task.dragging { opacity: 0.35; }
.planner-tasks.drop-target {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 8px;
outline: 2px dashed var(--accent);
}
.add-task-form { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; }
.add-task-form select, .add-task-form input { font-size: 0.85rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.or-divider { text-align: center; font-size: 0.75rem; color: var(--muted); }
@@ -477,3 +485,97 @@ td.overdue, .overdue { background: var(--bad-bg) !important; }
.grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; }
.comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; }
/* ---------- themes ---------- */
body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; }
body.theme-purple { --accent: #7c5cbf; --accent-dark: #6245a0; --bg: #f8f4ff; }
body.theme-orange { --accent: #e8802a; --accent-dark: #c96a1a; --bg: #fff8f0; }
body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
/* ---------- kid top bar + theme picker ---------- */
.kid-topbar { display: flex; justify-content: space-between; align-items: center; }
.theme-picker-btn {
background: none; border: none; font-size: 1.3rem; cursor: pointer;
padding: 0.25rem 0.5rem; color: var(--muted); line-height: 1;
}
.theme-dots {
display: flex; gap: 0.6rem; justify-content: flex-end;
margin-bottom: 0.75rem;
}
.theme-dot {
width: 2rem; height: 2rem; border-radius: 50%;
border: 3px solid transparent; cursor: pointer;
transition: transform 0.12s, border-color 0.12s;
}
.theme-dot:hover { transform: scale(1.2); }
.theme-dot.active { border-color: var(--ink); box-shadow: 0 0 0 2px var(--bg); }
.theme-dot.theme-blue { background: #4f86f7; }
.theme-dot.theme-green { background: #2e9e5b; }
.theme-dot.theme-purple { background: #7c5cbf; }
.theme-dot.theme-orange { background: #e8802a; }
.theme-dot.theme-pink { background: #d45d8a; }
/* ---------- celebration overlay ---------- */
#celebration-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0, 0, 0, 0.55);
display: flex; align-items: center; justify-content: center;
}
#celebration-content {
background: var(--card); border-radius: var(--radius);
padding: 2.5rem 2rem; text-align: center;
max-width: 360px; width: 92%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
animation: pop-in 0.25s ease;
}
@keyframes pop-in { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.celebration-emoji { font-size: 5rem; animation: bounce 0.6s ease infinite alternate; display: block; }
@keyframes bounce { to { transform: translateY(-14px); } }
#celebration-content h2 { font-size: 1.8rem; margin: 0.5rem 0 1rem; }
.streak-badge { font-size: 2.6rem; font-weight: 800; color: var(--accent); }
.streak-label { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
.best-streak-label { font-size: 0.85rem; color: var(--gold); font-weight: 700; margin-bottom: 0.5rem; }
/* ---------- streak pill on kid home ---------- */
.streak-pill {
display: inline-flex; align-items: center; gap: 0.3rem;
background: var(--good-bg); color: var(--good);
border-radius: 999px; padding: 0.25rem 0.9rem;
font-size: 0.95rem; font-weight: 700; margin-bottom: 0.75rem;
}
/* ---------- feedback indicator ---------- */
.feedback-badge {
display: inline-block; background: var(--gold); color: #333;
font-size: 0.7rem; font-weight: 800; border-radius: 999px;
padding: 0.1rem 0.45rem; margin-left: 0.35rem; vertical-align: middle;
}
.parent-comment {
background: var(--good-bg); border-radius: 10px;
padding: 0.9rem 1rem; margin: 0.75rem 0; color: var(--good);
border-left: 4px solid var(--good);
font-size: 1.05rem;
}
/* ---------- print list button ---------- */
.big-btn.print-list {
background: #6b7280; color: #fff; font-size: 1.2rem;
}
/* ---------- grading badge on admin nav ---------- */
.nav-pill.grading-pending { background: var(--bad); color: #fff; border-color: var(--bad); }
/* ---------- admin theme swatches ---------- */
.theme-swatch-row { display: flex; gap: 0.4rem; align-items: center; }
.theme-swatch {
width: 1.5rem; height: 1.5rem; border-radius: 50%;
border: 2px solid transparent; cursor: pointer;
transition: transform 0.1s;
}
.theme-swatch:hover { transform: scale(1.2); }
.theme-swatch.selected { border-color: var(--ink); }
.theme-swatch.theme-blue { background: #4f86f7; }
.theme-swatch.theme-green { background: #2e9e5b; }
.theme-swatch.theme-purple { background: #7c5cbf; }
.theme-swatch.theme-orange { background: #e8802a; }
.theme-swatch.theme-pink { background: #d45d8a; }