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:
1 parent
39d690bf93
commit
3c22fe05f8
9 files changed
+831
-17
No files matched your search
@@ -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');
|
||||||
|
|
||||||
|
|||||||
Generated
+225
-1
@@ -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
@@ -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
@@ -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. <iframe src="https://www.youtube.com/embed/VIDEO_ID"></iframe>"></textarea>
|
<textarea id="ie-body-lesson" rows="8" placeholder="Weather is what the sky and air are doing right now. <iframe src="https://www.youtube.com/embed/VIDEO_ID"></iframe>"></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: receive | Did you receive my letter? | to get or accept something"></textarea>
|
placeholder="One word per line. Paste any list from your curriculum! Add an example sentence with | receive | Did you receive my letter? Or just a plain list: because enough 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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user