Spaces:
Running
Running
Commit ·
45e9848
1
Parent(s): ed832a7
Add cascading Demo project picker
Browse files- classroom_static/admin.css +7 -1
- classroom_static/admin.html +3 -3
- classroom_static/admin.js +62 -21
classroom_static/admin.css
CHANGED
|
@@ -13,7 +13,13 @@
|
|
| 13 |
.admin-sidebar-head { min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.75rem 1rem;border-bottom:1px solid var(--line); }
|
| 14 |
.admin-sidebar-head strong,.admin-sidebar-head small { display:block; }
|
| 15 |
.admin-sidebar-head small { margin-top:.2rem;color:var(--muted);font-size:.75rem; }
|
| 16 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
.admin-list { min-height:0;overflow:auto;padding:.5rem; }
|
| 18 |
.admin-list-button { width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;padding:.7rem .75rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left; }
|
| 19 |
.admin-list-button:hover { background:var(--surface-subtle); }
|
|
|
|
| 13 |
.admin-sidebar-head { min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.75rem 1rem;border-bottom:1px solid var(--line); }
|
| 14 |
.admin-sidebar-head strong,.admin-sidebar-head small { display:block; }
|
| 15 |
.admin-sidebar-head small { margin-top:.2rem;color:var(--muted);font-size:.75rem; }
|
| 16 |
+
.demo-picker { display:grid;gap:1rem;padding:1rem;border-bottom:1px solid var(--line); }
|
| 17 |
+
.demo-picker label { display:grid;gap:.4rem;color:var(--muted);font-size:.78rem;font-weight:800; }
|
| 18 |
+
.demo-picker select { color:var(--ink);font-weight:650; }
|
| 19 |
+
.demo-picker select:disabled { background:var(--surface-subtle);color:var(--muted); }
|
| 20 |
+
.picker-summary { min-height:0;overflow:auto;padding:1rem;background:var(--surface-subtle); }
|
| 21 |
+
.picker-summary strong { display:block;color:var(--navy);font-size:.9rem; }
|
| 22 |
+
.picker-summary p { margin:.4rem 0 0;color:var(--muted);font-size:.78rem;line-height:1.5; }
|
| 23 |
.admin-list { min-height:0;overflow:auto;padding:.5rem; }
|
| 24 |
.admin-list-button { width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;padding:.7rem .75rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left; }
|
| 25 |
.admin-list-button:hover { background:var(--surface-subtle); }
|
classroom_static/admin.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
<title>Telearn 內容管理後台</title>
|
| 7 |
<link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-4">
|
| 8 |
-
<link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-
|
| 9 |
</head>
|
| 10 |
<body class="admin-body">
|
| 11 |
<header class="app-header">
|
|
@@ -25,7 +25,7 @@
|
|
| 25 |
</section>
|
| 26 |
|
| 27 |
<section class="admin-view" id="demoView">
|
| 28 |
-
<aside class="admin-sidebar"><div class="admin-sidebar-head"><div><strong>公開 Demo</strong><small>
|
| 29 |
<form class="admin-editor" id="demoForm">
|
| 30 |
<header class="admin-editor-head"><div><h1 id="demoHeading">選擇一個 Demo 專題</h1><p>文字程式可直接編輯;圖片、音效等資產會原樣保留。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteDemoButton" type="button" disabled>刪除</button><button class="btn" id="saveDemoButton" type="submit" disabled>儲存專題</button></div></header>
|
| 31 |
<div class="admin-fields three"><label>班級<input class="text-field" id="demoClass" required disabled></label><label>學生<input class="text-field" id="demoStudent" required disabled></label><label>專題名稱<input class="text-field" id="demoProject" required disabled></label></div>
|
|
@@ -36,6 +36,6 @@
|
|
| 36 |
|
| 37 |
<dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
|
| 38 |
<div class="toast" id="toast"></div>
|
| 39 |
-
<script src="/classroom-assets/admin.js?v=2026fall-
|
| 40 |
</body>
|
| 41 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
<title>Telearn 內容管理後台</title>
|
| 7 |
<link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-4">
|
| 8 |
+
<link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-5">
|
| 9 |
</head>
|
| 10 |
<body class="admin-body">
|
| 11 |
<header class="app-header">
|
|
|
|
| 25 |
</section>
|
| 26 |
|
| 27 |
<section class="admin-view" id="demoView">
|
| 28 |
+
<aside class="admin-sidebar demo-picker-sidebar"><div class="admin-sidebar-head"><div><strong>公開 Demo</strong><small id="demoTotal">依課程、學生、專題逐層選擇</small></div><button class="copy-button" id="newDemoButton">+新增</button></div><div class="demo-picker"><label><span>1 課程</span><select class="text-field" id="demoClassSelect"><option value="">請選擇課程</option></select></label><label><span>2 學生</span><select class="text-field" id="demoStudentSelect" disabled><option value="">請先選課程</option></select></label><label><span>3 專題</span><select class="text-field" id="demoProjectSelect" disabled><option value="">請先選學生</option></select></label></div><div class="picker-summary" id="demoPickerSummary"><strong>尚未選擇專題</strong><p>選好三個層級後,右側會載入可編輯的程式檔案。</p></div></aside>
|
| 29 |
<form class="admin-editor" id="demoForm">
|
| 30 |
<header class="admin-editor-head"><div><h1 id="demoHeading">選擇一個 Demo 專題</h1><p>文字程式可直接編輯;圖片、音效等資產會原樣保留。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteDemoButton" type="button" disabled>刪除</button><button class="btn" id="saveDemoButton" type="submit" disabled>儲存專題</button></div></header>
|
| 31 |
<div class="admin-fields three"><label>班級<input class="text-field" id="demoClass" required disabled></label><label>學生<input class="text-field" id="demoStudent" required disabled></label><label>專題名稱<input class="text-field" id="demoProject" required disabled></label></div>
|
|
|
|
| 36 |
|
| 37 |
<dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
|
| 38 |
<div class="toast" id="toast"></div>
|
| 39 |
+
<script src="/classroom-assets/admin.js?v=2026fall-5"></script>
|
| 40 |
</body>
|
| 41 |
</html>
|
classroom_static/admin.js
CHANGED
|
@@ -3,8 +3,9 @@ const E = {};
|
|
| 3 |
[
|
| 4 |
"adminStatus", "loginButton", "weekAdminList", "weekForm", "weekHeading",
|
| 5 |
"weekTitle", "weekProject", "weekOutput", "saveWeekButton", "newWeekFile",
|
| 6 |
-
"weekFiles", "weekFileLabel", "weekFileContent", "
|
| 7 |
-
"
|
|
|
|
| 8 |
"demoProject", "newDemoFile", "demoFiles", "demoFileLabel", "demoFileContent",
|
| 9 |
"demoAssets", "saveDemoButton", "deleteDemoButton", "adminLoginDialog",
|
| 10 |
"adminLoginForm", "adminPassword", "adminLoginError", "toast"
|
|
@@ -57,7 +58,7 @@ async function login() {
|
|
| 57 |
state = await request("/api/admin/state");
|
| 58 |
E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
|
| 59 |
renderWeeks();
|
| 60 |
-
|
| 61 |
if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
|
| 62 |
}
|
| 63 |
|
|
@@ -162,19 +163,56 @@ E.weekForm.onsubmit = async event => {
|
|
| 162 |
};
|
| 163 |
|
| 164 |
function demoKey(item) { return `${item.class_name}\n${item.student_name}\n${item.project_name}`; }
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
function enableDemoForm(enabled) {
|
| 180 |
[E.demoClass, E.demoStudent, E.demoProject, E.newDemoFile, E.saveDemoButton].forEach(element => element.disabled = !enabled);
|
|
@@ -184,7 +222,6 @@ function enableDemoForm(enabled) {
|
|
| 184 |
async function selectDemo(item) {
|
| 185 |
saveDemoFileBuffer();
|
| 186 |
originalDemo = { class_name: item.class_name, student_name: item.student_name, project_name: item.project_name };
|
| 187 |
-
renderDemoList();
|
| 188 |
const data = await request("/api/admin/demo/project", { project: originalDemo });
|
| 189 |
E.demoClass.value = item.class_name;
|
| 190 |
E.demoStudent.value = item.student_name;
|
|
@@ -193,6 +230,7 @@ async function selectDemo(item) {
|
|
| 193 |
demoFiles = data.files || {};
|
| 194 |
demoAssets = data.assets || [];
|
| 195 |
demoFile = Object.keys(demoFiles)[0] || "";
|
|
|
|
| 196 |
enableDemoForm(true);
|
| 197 |
renderDemoFiles();
|
| 198 |
}
|
|
@@ -220,16 +258,18 @@ function renderDemoFiles() {
|
|
| 220 |
}
|
| 221 |
|
| 222 |
E.newDemoButton.onclick = () => {
|
|
|
|
|
|
|
| 223 |
originalDemo = null;
|
| 224 |
demoFiles = { "main.py": '# 新 Demo 專題\nprint("Hello, Telearn!")\n' };
|
| 225 |
demoAssets = [];
|
| 226 |
demoFile = "main.py";
|
| 227 |
-
E.demoClass.value = "2026Fall進階專題";
|
| 228 |
-
E.demoStudent.value = "";
|
| 229 |
E.demoProject.value = "";
|
| 230 |
E.demoHeading.textContent = "建立新 Demo 專題";
|
| 231 |
enableDemoForm(true);
|
| 232 |
-
|
| 233 |
renderDemoFiles();
|
| 234 |
E.demoStudent.focus();
|
| 235 |
};
|
|
@@ -259,7 +299,8 @@ E.demoForm.onsubmit = async event => {
|
|
| 259 |
originalDemo = data.project;
|
| 260 |
state.demo_projects.push({ ...data.project, type: "main.py" in demoFiles ? "python" : "index.html" in demoFiles ? "html" : "external" });
|
| 261 |
E.demoHeading.textContent = data.project.project_name;
|
| 262 |
-
|
|
|
|
| 263 |
enableDemoForm(true);
|
| 264 |
toast("Demo 專題已儲存,Space 正在重新建置");
|
| 265 |
} catch (error) { toast(error.message); }
|
|
@@ -279,7 +320,7 @@ E.deleteDemoButton.onclick = async () => {
|
|
| 279 |
E.demoHeading.textContent = "選擇一個 Demo 專題";
|
| 280 |
[E.demoClass, E.demoStudent, E.demoProject].forEach(input => input.value = "");
|
| 281 |
enableDemoForm(false);
|
| 282 |
-
|
| 283 |
renderDemoFiles();
|
| 284 |
toast("Demo 專題已刪除,Space 正在重新建置");
|
| 285 |
} catch (error) { toast(error.message); enableDemoForm(true); }
|
|
|
|
| 3 |
[
|
| 4 |
"adminStatus", "loginButton", "weekAdminList", "weekForm", "weekHeading",
|
| 5 |
"weekTitle", "weekProject", "weekOutput", "saveWeekButton", "newWeekFile",
|
| 6 |
+
"weekFiles", "weekFileLabel", "weekFileContent", "demoTotal", "newDemoButton",
|
| 7 |
+
"demoClassSelect", "demoStudentSelect", "demoProjectSelect", "demoPickerSummary",
|
| 8 |
+
"demoForm", "demoHeading", "demoClass", "demoStudent",
|
| 9 |
"demoProject", "newDemoFile", "demoFiles", "demoFileLabel", "demoFileContent",
|
| 10 |
"demoAssets", "saveDemoButton", "deleteDemoButton", "adminLoginDialog",
|
| 11 |
"adminLoginForm", "adminPassword", "adminLoginError", "toast"
|
|
|
|
| 58 |
state = await request("/api/admin/state");
|
| 59 |
E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
|
| 60 |
renderWeeks();
|
| 61 |
+
renderDemoPicker();
|
| 62 |
if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
|
| 63 |
}
|
| 64 |
|
|
|
|
| 163 |
};
|
| 164 |
|
| 165 |
function demoKey(item) { return `${item.class_name}\n${item.student_name}\n${item.project_name}`; }
|
| 166 |
+
const sortedUnique = values => [...new Set(values)].sort((a, b) => a.localeCompare(b, "zh-Hant"));
|
| 167 |
+
|
| 168 |
+
function optionList(values, placeholder) {
|
| 169 |
+
return `<option value="">${placeholder}</option>` + values.map(value => `<option value="${esc(value)}">${esc(value)}</option>`).join("");
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function renderDemoPicker(selection = null) {
|
| 173 |
+
const classes = sortedUnique(state.demo_projects.map(item => item.class_name));
|
| 174 |
+
E.demoTotal.textContent = `${classes.length} 門課程 · ${state.demo_projects.length} 個專題`;
|
| 175 |
+
E.demoClassSelect.innerHTML = optionList(classes, "請選擇課程");
|
| 176 |
+
E.demoClassSelect.value = selection?.class_name || "";
|
| 177 |
+
renderStudentPicker(selection?.student_name || "", selection?.project_name || "");
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function renderStudentPicker(studentName = "", projectName = "") {
|
| 181 |
+
const className = E.demoClassSelect.value;
|
| 182 |
+
const students = sortedUnique(state.demo_projects.filter(item => item.class_name === className).map(item => item.student_name));
|
| 183 |
+
E.demoStudentSelect.innerHTML = optionList(students, className ? "請選擇學生" : "請先選課程");
|
| 184 |
+
E.demoStudentSelect.disabled = !className;
|
| 185 |
+
E.demoStudentSelect.value = students.includes(studentName) ? studentName : "";
|
| 186 |
+
renderProjectPicker(projectName);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function renderProjectPicker(projectName = "") {
|
| 190 |
+
const className = E.demoClassSelect.value;
|
| 191 |
+
const studentName = E.demoStudentSelect.value;
|
| 192 |
+
const projects = state.demo_projects.filter(item => item.class_name === className && item.student_name === studentName);
|
| 193 |
+
E.demoProjectSelect.innerHTML = optionList(projects.map(item => item.project_name), studentName ? "請選擇專題" : "請先選學生");
|
| 194 |
+
E.demoProjectSelect.disabled = !studentName;
|
| 195 |
+
E.demoProjectSelect.value = projects.some(item => item.project_name === projectName) ? projectName : "";
|
| 196 |
+
if (!E.demoProjectSelect.value) E.demoPickerSummary.innerHTML = `<strong>${studentName ? "請選擇專題" : className ? "請選擇學生" : "尚未選擇專題"}</strong><p>選好三個層級後,右側會載入可編輯的程式檔案。</p>`;
|
| 197 |
}
|
| 198 |
+
|
| 199 |
+
function resetDemoEditor() {
|
| 200 |
+
originalDemo = null;
|
| 201 |
+
demoFiles = {};
|
| 202 |
+
demoAssets = [];
|
| 203 |
+
demoFile = "";
|
| 204 |
+
E.demoHeading.textContent = "選擇一個 Demo 專題";
|
| 205 |
+
[E.demoClass, E.demoStudent, E.demoProject].forEach(input => input.value = "");
|
| 206 |
+
enableDemoForm(false);
|
| 207 |
+
renderDemoFiles();
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
E.demoClassSelect.onchange = () => { resetDemoEditor(); renderStudentPicker(); };
|
| 211 |
+
E.demoStudentSelect.onchange = () => { resetDemoEditor(); renderProjectPicker(); };
|
| 212 |
+
E.demoProjectSelect.onchange = () => {
|
| 213 |
+
const item = state.demo_projects.find(project => project.class_name === E.demoClassSelect.value && project.student_name === E.demoStudentSelect.value && project.project_name === E.demoProjectSelect.value);
|
| 214 |
+
if (item) selectDemo(item);
|
| 215 |
+
};
|
| 216 |
|
| 217 |
function enableDemoForm(enabled) {
|
| 218 |
[E.demoClass, E.demoStudent, E.demoProject, E.newDemoFile, E.saveDemoButton].forEach(element => element.disabled = !enabled);
|
|
|
|
| 222 |
async function selectDemo(item) {
|
| 223 |
saveDemoFileBuffer();
|
| 224 |
originalDemo = { class_name: item.class_name, student_name: item.student_name, project_name: item.project_name };
|
|
|
|
| 225 |
const data = await request("/api/admin/demo/project", { project: originalDemo });
|
| 226 |
E.demoClass.value = item.class_name;
|
| 227 |
E.demoStudent.value = item.student_name;
|
|
|
|
| 230 |
demoFiles = data.files || {};
|
| 231 |
demoAssets = data.assets || [];
|
| 232 |
demoFile = Object.keys(demoFiles)[0] || "";
|
| 233 |
+
E.demoPickerSummary.innerHTML = `<strong>${esc(item.project_name)}</strong><p>${esc(item.class_name)} → ${esc(item.student_name)} → ${esc(item.type)}</p>`;
|
| 234 |
enableDemoForm(true);
|
| 235 |
renderDemoFiles();
|
| 236 |
}
|
|
|
|
| 258 |
}
|
| 259 |
|
| 260 |
E.newDemoButton.onclick = () => {
|
| 261 |
+
const pickedClass = E.demoClassSelect.value;
|
| 262 |
+
const pickedStudent = E.demoStudentSelect.value;
|
| 263 |
originalDemo = null;
|
| 264 |
demoFiles = { "main.py": '# 新 Demo 專題\nprint("Hello, Telearn!")\n' };
|
| 265 |
demoAssets = [];
|
| 266 |
demoFile = "main.py";
|
| 267 |
+
E.demoClass.value = pickedClass || "2026Fall進階專題";
|
| 268 |
+
E.demoStudent.value = pickedStudent || "";
|
| 269 |
E.demoProject.value = "";
|
| 270 |
E.demoHeading.textContent = "建立新 Demo 專題";
|
| 271 |
enableDemoForm(true);
|
| 272 |
+
E.demoPickerSummary.innerHTML = `<strong>建立新專題</strong><p>會建立在右側填寫的課程與學生之下。</p>`;
|
| 273 |
renderDemoFiles();
|
| 274 |
E.demoStudent.focus();
|
| 275 |
};
|
|
|
|
| 299 |
originalDemo = data.project;
|
| 300 |
state.demo_projects.push({ ...data.project, type: "main.py" in demoFiles ? "python" : "index.html" in demoFiles ? "html" : "external" });
|
| 301 |
E.demoHeading.textContent = data.project.project_name;
|
| 302 |
+
renderDemoPicker(originalDemo);
|
| 303 |
+
E.demoPickerSummary.innerHTML = `<strong>${esc(data.project.project_name)}</strong><p>${esc(data.project.class_name)} → ${esc(data.project.student_name)}</p>`;
|
| 304 |
enableDemoForm(true);
|
| 305 |
toast("Demo 專題已儲存,Space 正在重新建置");
|
| 306 |
} catch (error) { toast(error.message); }
|
|
|
|
| 320 |
E.demoHeading.textContent = "選擇一個 Demo 專題";
|
| 321 |
[E.demoClass, E.demoStudent, E.demoProject].forEach(input => input.value = "");
|
| 322 |
enableDemoForm(false);
|
| 323 |
+
renderDemoPicker();
|
| 324 |
renderDemoFiles();
|
| 325 |
toast("Demo 專題已刪除,Space 正在重新建置");
|
| 326 |
} catch (error) { toast(error.message); enableDemoForm(true); }
|