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
+831 -17

No files matched your search

+4
View File
@@ -224,6 +224,10 @@ tryAlter(`ALTER TABLE schedule ADD COLUMN status TEXT NOT NULL DEFAULT 'not_star
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`); tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`);
tryAlter(`ALTER TABLE words ADD COLUMN definition TEXT NOT NULL DEFAULT ''`); tryAlter(`ALTER TABLE words ADD COLUMN definition TEXT NOT NULL DEFAULT ''`);
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`); tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`);
tryAlter(`ALTER TABLE students ADD COLUMN theme TEXT NOT NULL DEFAULT 'blue'`);
tryAlter(`ALTER TABLE students ADD COLUMN streak_date TEXT`);
tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT 0`);
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex'); export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
+225 -1
View File
@@ -9,12 +9,22 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "^5.1.0" "express": "^5.1.0",
"mammoth": "^1.12.0"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=24"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -28,6 +38,41 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -143,6 +188,12 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -169,6 +220,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -443,6 +509,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -464,6 +536,68 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -567,6 +701,18 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -576,6 +722,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -586,6 +741,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -643,6 +804,21 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -659,6 +835,12 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -710,6 +892,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -788,6 +976,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -797,6 +991,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -837,6 +1040,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -846,6 +1055,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -860,6 +1075,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
} }
} }
} }
+2 -1
View File
@@ -11,7 +11,8 @@
"author": "NateEmmert", "author": "NateEmmert",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "^5.1.0" "express": "^5.1.0",
"mammoth": "^1.12.0"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=24"
+13 -1
View File
@@ -45,6 +45,7 @@
<select id="new-student-emoji" aria-label="Avatar"> <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> <option>🐼</option><option>🦖</option><option>🐙</option><option>🦉</option>
<option>🐻</option><option>🐰</option><option>🦁</option><option>🐶</option>
</select> </select>
<button type="submit">Add kid</button> <button type="submit">Add kid</button>
</form> </form>
@@ -149,6 +150,17 @@
<p id="scan-status" class="hint"></p> <p id="scan-status" class="hint"></p>
</div> </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"> <div id="ie-field-lesson" class="ie-field">
<label>Lesson content (text, or paste HTML — images/YouTube embeds allowed) <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> <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 type="hidden" id="list-id">
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name"> <input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
<textarea id="list-words" rows="8" <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"> <div class="row-actions">
<button type="submit">Save list</button> <button type="submit">Save list</button>
<button type="button" id="list-cancel" class="secondary">Clear</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; $('#pin-gate').hidden = true;
$('#console').hidden = false; $('#console').hidden = false;
showPanel('kids'); 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(); if (parentPin) unlock();
@@ -81,20 +96,41 @@ function showPanel(name) {
// Kids // Kids
// ============================================================ // ============================================================
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a' };
async function loadKids() { async function loadKids() {
const students = await api('/api/students'); const students = await api('/api/students');
$('#student-rows').innerHTML = students.length $('#student-rows').innerHTML = students.length
? students ? students
.map( .map((s) => {
(s) => `<div class="item-row"> const theme = s.theme || 'blue';
<span>${esc(s.emoji)}</span> 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> <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> <button class="danger" data-del-student="${s.id}">Remove</button>
</div>` </div>`;
) })
.join('') .join('')
: `<p class="hint">No kids yet — add one below.</p>`; : `<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) => document.querySelectorAll('[data-del-student]').forEach((btn) =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return; if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return;
@@ -332,6 +368,7 @@ async function openItemEditor(unitId, itemId) {
} }
updateItemFieldVisibility(); updateItemFieldVisibility();
resetScanSection(); resetScanSection();
resetImportSection();
showPanel('item-editor'); showPanel('item-editor');
} }
@@ -343,6 +380,7 @@ function updateItemFieldVisibility() {
$('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test';
$('#ie-field-flashcards').hidden = type !== 'flashcards'; $('#ie-field-flashcards').hidden = type !== 'flashcards';
$('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; $('#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); $('#ie-type').addEventListener('change', updateItemFieldVisibility);
@@ -350,6 +388,12 @@ $('#ie-type').addEventListener('change', updateItemFieldVisibility);
let scanImageBase64 = null; 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() { function resetScanSection() {
scanImageBase64 = null; scanImageBase64 = null;
$('#scan-file-input').value = ''; $('#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 = {}) { function addQuestionRow(q = {}) {
const n = questionCount++; const n = questionCount++;
const div = document.createElement('div'); const div = document.createElement('div');
@@ -597,12 +700,12 @@ async function renderPlanner() {
$('#planner-grid').innerHTML = days $('#planner-grid').innerHTML = days
.map((day) => { .map((day) => {
const dayTasks = tasks.filter((t) => t.date === day.date); 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> <h3>${day.name}<small>${day.date}</small></h3>
<div class="planner-tasks"> <div class="planner-tasks" data-drop-date="${day.date}">
${dayTasks ${dayTasks
.map( .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> <span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button> <button class="danger tiny" data-del-schedule="${t.id}">✕</button>
</div>` </div>`
@@ -615,6 +718,39 @@ async function renderPlanner() {
}) })
.join(''); .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) => document.querySelectorAll('[data-del-schedule]').forEach((btn) =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' });
@@ -768,6 +904,7 @@ async function loadGrading() {
body: { score: Number(input.value), parentComment: commentInput?.value || '' }, body: { score: Number(input.value), parentComment: commentInput?.value || '' },
}); });
loadGrading(); loadGrading();
loadGradingBadge();
}); });
}); });
} }
@@ -886,12 +1023,20 @@ function renderLists(lists) {
(l) => `<div class="item-row"> (l) => `<div class="item-row">
<strong class="grow">${esc(l.name)}</strong> <strong class="grow">${esc(l.name)}</strong>
<span>${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span> <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> <button data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin ? '' : `<button class="danger" data-del-list="${l.id}">Delete</button>`} ${l.builtin ? '' : `<button class="danger" data-del-list="${l.id}">Delete</button>`}
</div>` </div>`
) )
.join(''); .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) => document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`); 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))); 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) { async function printSpellingReport(testId) {
const r = await api(`/api/test-report/${testId}`); const r = await api(`/api/test-report/${testId}`);
const rows = r.answers const rows = r.answers
+135 -3
View File
@@ -183,16 +183,41 @@ function diffLetters(typed, correct) {
return { typedHtml: typedParts.join(''), correctHtml: correctParts.join('') }; 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) ---------- // ---------- home (kid picker) ----------
let currentStudent = null; let currentStudent = null;
async function loadHome() { async function loadHome() {
resetTheme();
const students = await api('/api/students'); const students = await api('/api/students');
$('#no-kids').hidden = students.length > 0; $('#no-kids').hidden = students.length > 0;
$('#kid-cards').innerHTML = students $('#kid-cards').innerHTML = students
.map( .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>` <span class="avatar">${esc(s.emoji)}</span>${esc(s.name)}</button>`
) )
.join(''); .join('');
@@ -219,15 +244,78 @@ async function openKid(id, tab = 'today') {
api(`/api/courses/mine/${id}`), api(`/api/courses/mine/${id}`),
]); ]);
currentStudent = state.student; currentStudent = state.student;
applyTheme(currentStudent.theme || 'blue');
updateThemeDots(currentStudent.theme || 'blue');
$('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`; $('#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); renderToday(agenda.tasks);
renderCourseCards(courses); renderCourseCards(courses);
renderSpellingTab(state); renderSpellingTab(state);
show('kid'); show('kid');
switchTab(tab); 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: '✅' }; const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
@@ -263,9 +351,10 @@ function renderToday(tasks) {
</div>`; </div>`;
} }
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done); 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}"> 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-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> <span class="row-badge">${badge}</span>
</button>`; </button>`;
}) })
@@ -341,6 +430,9 @@ function renderCourseCards(courses) {
); );
} }
let currentSpellingListId = null;
let currentSpellingListName = '';
function renderSpellingTab(state) { function renderSpellingTab(state) {
let html; let html;
if (state.assignment) { if (state.assignment) {
@@ -348,14 +440,54 @@ function renderSpellingTab(state) {
html = `<strong>This week: ${esc(state.assignment.name)}</strong> html = `<strong>This week: ${esc(state.assignment.name)}</strong>
<div class="mastery-dots">${'🌟'.repeat(mastered)}${'⚪'.repeat(Math.max(0, total - mastered))}</div> <div class="mastery-dots">${'🌟'.repeat(mastered)}${'⚪'.repeat(Math.max(0, total - mastered))}</div>
<div>${mastered} of ${total} words mastered</div>`; <div>${mastered} of ${total} words mastered</div>`;
currentSpellingListId = state.assignment.id;
currentSpellingListName = state.assignment.name;
} else { } else {
html = `No list assigned yet — ask a parent to pick one!`; html = `No list assigned yet — ask a parent to pick one!`;
currentSpellingListId = null;
} }
if (state.dueReviews > 0) { if (state.dueReviews > 0) {
html += `<div>🔁 ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review</div>`; html += `<div>🔁 ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review</div>`;
} }
$('#kid-week').innerHTML = html; $('#kid-week').innerHTML = html;
$('#btn-test').disabled = !state.assignment; $('#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 ---------- // ---------- course detail ----------
@@ -450,7 +582,7 @@ async function openAssignment(itemId) {
// Show parent comment if graded // Show parent comment if graded
if (item.submission?.parent_comment) { if (item.submission?.parent_comment) {
commentEl.hidden = false; 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 { } else {
commentEl.hidden = true; commentEl.hidden = true;
} }
+21
View File
@@ -19,8 +19,18 @@
<!-- Kid home: Today's work + nav --> <!-- Kid home: Today's work + nav -->
<section id="view-kid" class="view" hidden> <section id="view-kid" class="view" hidden>
<div class="kid-topbar">
<button class="back-btn" data-nav="home">← Switch</button> <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> <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"> <div class="kid-nav">
<button class="nav-pill active" data-tab="today">📋 Today</button> <button class="nav-pill active" data-tab="today">📋 Today</button>
@@ -43,10 +53,21 @@
<div class="big-actions"> <div class="big-actions">
<button id="btn-practice" class="big-btn practice">✏️ Practice</button> <button id="btn-practice" class="big-btn practice">✏️ Practice</button>
<button id="btn-test" class="big-btn test">⭐ Friday Test</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>
</div> </div>
</section> </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 --> <!-- Course detail: units + items -->
<section id="view-course" class="view" hidden> <section id="view-course" class="view" hidden>
<button class="back-btn" data-back="course">← Back</button> <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; 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.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 { 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; } .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); } .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; } .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; } .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; } .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; }
+146 -3
View File
@@ -2,8 +2,10 @@ import express from 'express';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import mammoth from 'mammoth';
import { db, sha256 } from './db.js'; import { db, sha256 } from './db.js';
const ROOT = dirname(fileURLToPath(import.meta.url)); const ROOT = dirname(fileURLToPath(import.meta.url));
@@ -81,7 +83,28 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'spelling_test'];
// ---------- kid picker ---------- // ---------- kid picker ----------
app.get('/api/students', (req, res) => { app.get('/api/students', (req, res) => {
res.json(db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all()); res.json(db.prepare(`SELECT id, name, emoji, theme, streak_count, streak_date FROM students ORDER BY name`).all());
});
app.patch('/api/students/:id/theme', (req, res) => {
const theme = ['blue','green','purple','orange','pink'].includes(req.body.theme) ? req.body.theme : 'blue';
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
res.json({ ok: true });
});
app.post('/api/students/:id/complete-day', (req, res) => {
const student = db.prepare(`SELECT streak_date, streak_count, best_streak FROM students WHERE id = ?`).get(req.params.id);
if (!student) return res.status(404).json({ error: 'Not found' });
const t = today();
if (student.streak_date === t) return res.json({ streak: student.streak_count, best: student.best_streak, alreadyCounted: true });
const d = new Date(t + 'T00:00:00');
d.setDate(d.getDate() - 1);
const yesterday = d.toISOString().slice(0, 10);
const newStreak = student.streak_date === yesterday ? student.streak_count + 1 : 1;
const newBest = Math.max(newStreak, student.best_streak);
db.prepare(`UPDATE students SET streak_date = ?, streak_count = ?, best_streak = ? WHERE id = ?`)
.run(t, newStreak, newBest, req.params.id);
res.json({ streak: newStreak, best: newBest, alreadyCounted: false });
}); });
// ---------- spelling module ---------- // ---------- spelling module ----------
@@ -377,6 +400,14 @@ app.post('/api/students', requirePin, (req, res) => {
res.json({ id }); res.json({ id });
}); });
app.put('/api/students/:id', requirePin, (req, res) => {
const { emoji, theme } = req.body;
if (emoji) db.prepare(`UPDATE students SET emoji = ? WHERE id = ?`).run(String(emoji), req.params.id);
if (theme && ['blue','green','purple','orange','pink'].includes(theme))
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
res.json({ ok: true });
});
app.delete('/api/students/:id', requirePin, (req, res) => { app.delete('/api/students/:id', requirePin, (req, res) => {
db.prepare(`DELETE FROM students WHERE id = ?`).run(req.params.id); db.prepare(`DELETE FROM students WHERE id = ?`).run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
@@ -757,6 +788,15 @@ app.post('/api/items/:id/quiz-submit', (req, res) => {
// ---------- grading (parent) ---------- // ---------- grading (parent) ----------
app.get('/api/grading-queue/count', requirePin, (req, res) => {
const { n } = db.prepare(`
SELECT COUNT(*) AS n FROM submissions sub
JOIN items i ON i.id = sub.item_id
WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards')
`).get();
res.json({ count: n });
});
app.get('/api/grading-queue', requirePin, (req, res) => { app.get('/api/grading-queue', requirePin, (req, res) => {
res.json(db.prepare(` res.json(db.prepare(`
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible, SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible,
@@ -949,7 +989,7 @@ app.get('/api/schedule/:studentId', (req, res) => {
sc.evidence_notes IS NOT NULL OR sc.evidence_photo IS NOT NULL AS hasEvidence, sc.evidence_notes IS NOT NULL OR sc.evidence_photo IS NOT NULL AS hasEvidence,
i.id AS itemId, i.type, i.title AS itemTitle, i.id AS itemId, i.type, i.title AS itemTitle,
c.name AS courseName, c.color AS courseColor, c.name AS courseName, c.color AS courseColor,
sub.status AS subStatus, sub.score, sub.points_possible sub.status AS subStatus, sub.score, sub.points_possible, sub.parent_comment AS parentComment
FROM schedule sc FROM schedule sc
LEFT JOIN items i ON i.id = sc.item_id LEFT JOIN items i ON i.id = sc.item_id
LEFT JOIN units u ON u.id = i.unit_id LEFT JOIN units u ON u.id = i.unit_id
@@ -1039,6 +1079,13 @@ app.post('/api/schedule', requirePin, (req, res) => {
res.json({ id }); res.json({ id });
}); });
app.patch('/api/schedule/:id', requirePin, (req, res) => {
const { date } = req.body;
if (!isDateStr(date)) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
db.prepare(`UPDATE schedule SET date = ? WHERE id = ?`).run(date, req.params.id);
res.json({ ok: true });
});
app.delete('/api/schedule/:id', requirePin, (req, res) => { app.delete('/api/schedule/:id', requirePin, (req, res) => {
db.prepare(`DELETE FROM schedule WHERE id = ?`).run(req.params.id); db.prepare(`DELETE FROM schedule WHERE id = ?`).run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
@@ -1319,6 +1366,102 @@ app.get('/api/tts', (req, res) => {
res.send(readFileSync(cachePath)); res.send(readFileSync(cachePath));
}); });
// ---------- Homeschool folder import ----------
const HOMESCHOOL_DIR = "/Users/nateemmert/Library/CloudStorage/SeaDrive-NateEmmert(seafile.necloud.us)/My Libraries/Homeschool";
function listDocxFiles(dir, base = '') {
const results = [];
let entries;
try { entries = readdirSync(dir); } catch { return results; }
for (const name of entries) {
const full = join(dir, name);
let stat;
try { stat = statSync(full); } catch { continue; }
if (stat.isDirectory()) {
results.push(...listDocxFiles(full, base ? `${base}/${name}` : name));
} else if (name.toLowerCase().endsWith('.docx') && !name.startsWith('~$')) {
results.push({ name, path: base ? `${base}/${name}` : name, full });
}
}
return results;
}
app.get('/api/admin/homeschool-files', requirePin, (req, res) => {
const files = listDocxFiles(HOMESCHOOL_DIR);
res.json({ files: files.map(({ name, path }) => ({ name, path })) });
});
const TEXT_SCAN_PROMPTS = {
lesson: `You are given the text content of a homeschool lesson or assignment document. Extract it as structured content.
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
{"title": "the document title or heading", "body": "the full text content, paragraphs separated by a blank line"}`,
quiz: `You are given the text content of a homeschool quiz or test document. Extract every question exactly as written.
For each question determine:
- "type": "mc" if it has lettered/numbered answer choices, "tf" if it is true/false, "short" if it expects a written answer
- "prompt": the exact question text
- "choices": for "mc", an array of the exact answer choice texts (empty array for "tf" or "short")
- "correctAnswer": the marked answer if clearly indicated; for "tf" use exactly "true" or "false"; otherwise use ""
- "points": 1 unless the document states a different value
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
{"title": "the quiz/test title", "questions": [{"type": "mc", "prompt": "...", "choices": ["...", "..."], "correctAnswer": "...", "points": 1}]}`,
};
app.post('/api/admin/import-docx', requirePin, async (req, res) => {
const { path: relPath, mode } = req.body;
if (!relPath || !TEXT_SCAN_PROMPTS[mode]) {
return res.status(400).json({ error: 'path and mode (lesson or quiz) required' });
}
const fullPath = join(HOMESCHOOL_DIR, relPath);
if (!fullPath.startsWith(HOMESCHOOL_DIR)) {
return res.status(403).json({ error: 'Access denied' });
}
let buffer;
try { buffer = await readFile(fullPath); }
catch (err) { return res.status(404).json({ error: `Could not read file: ${err.message}` }); }
let docText;
try {
const result = await mammoth.extractRawText({ buffer });
docText = result.value.trim();
} catch (err) {
return res.status(500).json({ error: `Could not parse docx: ${err.message}` });
}
if (!docText) return res.status(400).json({ error: 'Document appears to be empty' });
const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, '');
const model = getSetting('ollama_model') || 'llava';
const prompt = TEXT_SCAN_PROMPTS[mode] + '\n\nDOCUMENT TEXT:\n' + docText;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 180_000);
try {
const r = await fetch(`${host}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, stream: false, format: 'json' }),
signal: controller.signal,
});
if (!r.ok) {
const t = await r.text().catch(() => '');
throw new Error(`Ollama error (${r.status}): ${t.slice(0, 200)}`);
}
const data = await r.json();
const parsed = parseVisionJson(data.response);
return res.json(parsed);
} catch (err) {
if (err.name === 'AbortError') return res.status(504).json({ error: 'Ollama timed out.' });
return res.status(502).json({ error: err.message });
} finally {
clearTimeout(timer);
}
});
app.get('/health', (req, res) => res.json({ ok: true })); app.get('/health', (req, res) => res.json({ ok: true }));
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;