steveyehtw commited on
Commit
45e9848
·
1 Parent(s): ed832a7

Add cascading Demo project picker

Browse files
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
- .admin-search { padding:.75rem;border-bottom:1px solid var(--line); }
 
 
 
 
 
 
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-4">
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>可搜尋、編輯或新增專題</small></div><button class="copy-button" id="newDemoButton">+新增</button></div><div class="admin-search"><input class="text-field" id="demoSearch" type="search" placeholder="搜尋班級、學生或專題"></div><div class="admin-list" id="demoAdminList"></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,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-4"></script>
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", "demoSearch", "newDemoButton",
7
- "demoAdminList", "demoForm", "demoHeading", "demoClass", "demoStudent",
 
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
- renderDemoList();
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
- function renderDemoList() {
166
- const query = E.demoSearch.value.trim().toLowerCase();
167
- const visible = state.demo_projects.filter(item => `${item.class_name} ${item.student_name} ${item.project_name}`.toLowerCase().includes(query));
168
- E.demoAdminList.innerHTML = visible.map(item => `
169
- <button class="admin-list-button ${originalDemo && demoKey(item) === demoKey(originalDemo) ? "active" : ""}" data-key="${esc(demoKey(item))}">
170
- <div><strong>${esc(item.project_name)}</strong><small>${esc(item.class_name)} · ${esc(item.student_name)}</small></div><span>${esc(item.type)}</span>
171
- </button>`).join("") || '<div class="admin-empty">找不到符合的 Demo 專題。</div>';
172
- E.demoAdminList.querySelectorAll("[data-key]").forEach(button => button.onclick = () => {
173
- const item = state.demo_projects.find(project => demoKey(project) === button.dataset.key);
174
- if (item) selectDemo(item);
175
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  }
177
- E.demoSearch.oninput = renderDemoList;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- renderDemoList();
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
- renderDemoList();
 
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
- renderDemoList();
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); }