teacher view shows realtime updates, allows student account creation, pushes code
parent
8d661307b9
commit
be5b95b311
10
server/db.js
10
server/db.js
|
|
@ -76,6 +76,16 @@ export async function createUser({ username, passwordHash, role = 'student' }) {
|
||||||
return { id: result.insertId, username, role };
|
return { id: result.insertId, username, role };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listStudents() {
|
||||||
|
const [rows] = await getPool().query(
|
||||||
|
`SELECT id, username, role, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE role = 'student'
|
||||||
|
ORDER BY username ASC`,
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSession({ token, userId, expiresAt }) {
|
export async function createSession({ token, userId, expiresAt }) {
|
||||||
await getPool().query(
|
await getPool().query(
|
||||||
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
|
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import authRouter from './auth.js';
|
import authRouter from './auth.js';
|
||||||
import projectsRouter from './projects.js';
|
import projectsRouter from './projects.js';
|
||||||
|
import teacherRouter from './teacher.js';
|
||||||
import { bootstrap, purgeExpiredSessions } from './db.js';
|
import { bootstrap, purgeExpiredSessions } from './db.js';
|
||||||
import { attachWsServer } from './ws.js';
|
import { attachWsServer } from './ws.js';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
app.use('/api/projects', projectsRouter);
|
app.use('/api/projects', projectsRouter);
|
||||||
|
app.use('/api/teacher', teacherRouter);
|
||||||
|
|
||||||
// ─── Helpers (Arduino CLI) ───────────────────────────────
|
// ─── Helpers (Arduino CLI) ───────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { requireTeacher } from './auth.js';
|
||||||
|
import { findUserByUsername, createUser, listStudents } from './db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const BCRYPT_ROUNDS = 8;
|
||||||
|
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,64}$/;
|
||||||
|
|
||||||
|
const WORDS_A = ['red', 'blue', 'green', 'happy', 'sunny', 'brave', 'tiny', 'swift', 'kind', 'bright'];
|
||||||
|
const WORDS_B = ['cat', 'dog', 'fox', 'duck', 'frog', 'bear', 'star', 'moon', 'kite', 'fish'];
|
||||||
|
const WORDS_C = ['jump', 'play', 'run', 'smile', 'spin', 'dance', 'wave', 'clap', 'sing', 'grow'];
|
||||||
|
|
||||||
|
function sanitizeStudent(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
role: row.role,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomItem(list) {
|
||||||
|
return list[Math.floor(Math.random() * list.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateUsername() {
|
||||||
|
const useThree = Math.random() < 0.45;
|
||||||
|
const first = randomItem(WORDS_A);
|
||||||
|
const second = randomItem(WORDS_B);
|
||||||
|
if (!useThree) return `${first}${second}`;
|
||||||
|
const third = randomItem(WORDS_C);
|
||||||
|
return `${first}${second}${third}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateUniqueUsername() {
|
||||||
|
for (let i = 0; i < 40; i += 1) {
|
||||||
|
const candidate = candidateUsername();
|
||||||
|
const existing = await findUserByUsername(candidate);
|
||||||
|
if (!existing) return candidate;
|
||||||
|
}
|
||||||
|
return `${candidateUsername()}${Math.floor(100 + Math.random() * 900)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/students', requireTeacher, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const students = (await listStudents()).map(sanitizeStudent);
|
||||||
|
res.json({ students });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/students/suggest-username', requireTeacher, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const username = await generateUniqueUsername();
|
||||||
|
res.json({ username });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/students', requireTeacher, async (req, res) => {
|
||||||
|
let { username } = req.body || {};
|
||||||
|
try {
|
||||||
|
username = username ? String(username).trim() : '';
|
||||||
|
if (!username) username = await generateUniqueUsername();
|
||||||
|
if (!USERNAME_RE.test(username)) {
|
||||||
|
return res.status(400).json({ error: 'username must be 3-64 chars, letters/digits/._-' });
|
||||||
|
}
|
||||||
|
const existing = await findUserByUsername(username);
|
||||||
|
if (existing) return res.status(409).json({ error: 'username already taken' });
|
||||||
|
|
||||||
|
// Requested behavior: default password equals username.
|
||||||
|
const hash = await bcrypt.hash(username, BCRYPT_ROUNDS);
|
||||||
|
const user = await createUser({ username, passwordHash: hash, role: 'student' });
|
||||||
|
res.json({ student: sanitizeStudent(user), defaultPassword: username });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -106,6 +106,15 @@ export function attachWsServer(server) {
|
||||||
send(ws, { type: 'push-code-result', ok: false, targetUserId: msg.targetUserId, error: 'Student not online' });
|
send(ws, { type: 'push-code-result', ok: false, targetUserId: msg.targetUserId, error: 'Student not online' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Update the cached student state immediately so teacher dashboards
|
||||||
|
// reflect pushed code without waiting for the next student edit.
|
||||||
|
target.lastState = msg.state || null;
|
||||||
|
broadcastToTeachers({
|
||||||
|
type: 'student-update',
|
||||||
|
userId: target.userId,
|
||||||
|
username: target.username,
|
||||||
|
state: target.lastState,
|
||||||
|
});
|
||||||
send(target.ws, { type: 'push-code', state: msg.state || null, from: ws.user.username });
|
send(target.ws, { type: 'push-code', state: msg.state || null, from: ws.user.username });
|
||||||
send(ws, { type: 'push-code-result', ok: true, targetUserId: target.userId });
|
send(ws, { type: 'push-code-result', ok: true, targetUserId: target.userId });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,56 @@ body.teacher-body {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin-row button {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-student-admin-row button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.teacher-empty {
|
.teacher-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -113,6 +163,10 @@ body.teacher-body {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.student-tile-status.offline {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.student-tile-blockly {
|
.student-tile-blockly {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
|
|
|
||||||
135
src/teacher.js
135
src/teacher.js
|
|
@ -33,8 +33,12 @@ const modalClose = document.getElementById('student-modal-close');
|
||||||
const modalPushBtn = document.getElementById('student-push-btn');
|
const modalPushBtn = document.getElementById('student-push-btn');
|
||||||
const modalStatus = document.getElementById('student-modal-status');
|
const modalStatus = document.getElementById('student-modal-status');
|
||||||
const hiddenWsDiv = document.getElementById('teacher-hidden-ws');
|
const hiddenWsDiv = document.getElementById('teacher-hidden-ws');
|
||||||
|
const createUsernameInput = document.getElementById('teacher-new-student-username');
|
||||||
|
const createStudentBtn = document.getElementById('teacher-create-student');
|
||||||
|
const generateUsernameBtn = document.getElementById('teacher-generate-username');
|
||||||
|
const createStudentStatus = document.getElementById('teacher-create-student-status');
|
||||||
|
|
||||||
// Map userId -> { username, state, tileEl, blockly, emptyLabel }
|
// Map userId -> { username, state, online, tileEl, blockly, emptyLabel }
|
||||||
const students = new Map();
|
const students = new Map();
|
||||||
let modalBlockly = null;
|
let modalBlockly = null;
|
||||||
let focusedUserId = null;
|
let focusedUserId = null;
|
||||||
|
|
@ -88,9 +92,10 @@ function setStatus(text, cls) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
const n = students.size;
|
const total = students.size;
|
||||||
countEl.textContent = `${n} student${n === 1 ? '' : 's'} online`;
|
const online = Array.from(students.values()).filter((s) => s.online).length;
|
||||||
emptyEl.classList.toggle('hidden', n > 0);
|
countEl.textContent = `${online} online / ${total} total`;
|
||||||
|
emptyEl.classList.toggle('hidden', total > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTile(userId, username) {
|
function createTile(userId, username) {
|
||||||
|
|
@ -135,7 +140,12 @@ function createTile(userId, username) {
|
||||||
move: { scrollbars: false, drag: false, wheel: false },
|
move: { scrollbars: false, drag: false, wheel: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { tile, blockly, blocklyHost, emptyLabel, nameSpan };
|
return { tile, blockly, blocklyHost, emptyLabel, nameSpan, statusSpan };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTileOnlineState(entry) {
|
||||||
|
entry.statusSpan.textContent = entry.online ? '● live' : '● offline';
|
||||||
|
entry.statusSpan.classList.toggle('offline', !entry.online);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStateToWorkspace(state, blocklyWs) {
|
function applyStateToWorkspace(state, blocklyWs) {
|
||||||
|
|
@ -152,7 +162,7 @@ function applyStateToWorkspace(state, blocklyWs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertStudent({ userId, username, state }) {
|
function upsertStudent({ userId, username, state, online = false }) {
|
||||||
let entry = students.get(userId);
|
let entry = students.get(userId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
const parts = createTile(userId, username);
|
const parts = createTile(userId, username);
|
||||||
|
|
@ -160,6 +170,7 @@ function upsertStudent({ userId, username, state }) {
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
state: null,
|
state: null,
|
||||||
|
online,
|
||||||
...parts,
|
...parts,
|
||||||
};
|
};
|
||||||
students.set(userId, entry);
|
students.set(userId, entry);
|
||||||
|
|
@ -168,6 +179,8 @@ function upsertStudent({ userId, username, state }) {
|
||||||
entry.username = username;
|
entry.username = username;
|
||||||
entry.nameSpan.textContent = username;
|
entry.nameSpan.textContent = username;
|
||||||
}
|
}
|
||||||
|
entry.online = !!online;
|
||||||
|
updateTileOnlineState(entry);
|
||||||
entry.state = state || null;
|
entry.state = state || null;
|
||||||
const hasBlocks = applyStateToWorkspace(entry.state, entry.blockly);
|
const hasBlocks = applyStateToWorkspace(entry.state, entry.blockly);
|
||||||
entry.emptyLabel.style.display = hasBlocks ? 'none' : '';
|
entry.emptyLabel.style.display = hasBlocks ? 'none' : '';
|
||||||
|
|
@ -192,6 +205,50 @@ function clearAllStudents() {
|
||||||
for (const id of Array.from(students.keys())) removeStudent(id);
|
for (const id of Array.from(students.keys())) removeStudent(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCreateStatus(text, isError = false) {
|
||||||
|
if (!createStudentStatus) return;
|
||||||
|
createStudentStatus.textContent = text || '';
|
||||||
|
createStudentStatus.className = `login-status${isError ? ' status-err' : ' status-ok'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiUrl(path) {
|
||||||
|
const base = import.meta.env.BASE_URL || '/';
|
||||||
|
const p = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
return (base.endsWith('/') ? base : base + '/') + p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teacherRequest(path, { method = 'GET', body } = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(apiUrl(path), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
let data = null;
|
||||||
|
try { data = await res.json(); } catch { /* ignore */ }
|
||||||
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllStudentsFromServer() {
|
||||||
|
try {
|
||||||
|
const data = await teacherRequest('/api/teacher/students');
|
||||||
|
for (const s of data.students || []) {
|
||||||
|
upsertStudent({
|
||||||
|
userId: s.id,
|
||||||
|
username: s.username,
|
||||||
|
state: null,
|
||||||
|
online: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateCount();
|
||||||
|
} catch {
|
||||||
|
// ignore; websocket still provides live students
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshModalFromEntry(entry) {
|
function refreshModalFromEntry(entry) {
|
||||||
if (!modalBlockly) return;
|
if (!modalBlockly) return;
|
||||||
modalTitle.textContent = entry.username;
|
modalTitle.textContent = entry.username;
|
||||||
|
|
@ -257,22 +314,29 @@ modalPushBtn.addEventListener('click', () => {
|
||||||
|
|
||||||
function handleMessage(msg) {
|
function handleMessage(msg) {
|
||||||
if (msg.type === 'roster') {
|
if (msg.type === 'roster') {
|
||||||
const incoming = new Set();
|
for (const entry of students.values()) {
|
||||||
|
entry.online = false;
|
||||||
|
updateTileOnlineState(entry);
|
||||||
|
}
|
||||||
for (const s of msg.students || []) {
|
for (const s of msg.students || []) {
|
||||||
incoming.add(s.userId);
|
upsertStudent({ ...s, online: true });
|
||||||
upsertStudent(s);
|
|
||||||
}
|
|
||||||
for (const id of Array.from(students.keys())) {
|
|
||||||
if (!incoming.has(id)) removeStudent(id);
|
|
||||||
}
|
}
|
||||||
|
updateCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'student-update') {
|
if (msg.type === 'student-update') {
|
||||||
upsertStudent({ userId: msg.userId, username: msg.username, state: msg.state });
|
upsertStudent({ userId: msg.userId, username: msg.username, state: msg.state, online: true });
|
||||||
|
updateCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'student-leave') {
|
if (msg.type === 'student-leave') {
|
||||||
removeStudent(msg.userId);
|
const entry = students.get(msg.userId);
|
||||||
|
if (entry) {
|
||||||
|
entry.online = false;
|
||||||
|
updateTileOnlineState(entry);
|
||||||
|
updateCount();
|
||||||
|
if (focusedUserId === msg.userId) refreshModalFromEntry(entry);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'push-code-result') {
|
if (msg.type === 'push-code-result') {
|
||||||
|
|
@ -318,6 +382,7 @@ function connectIfTeacher() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('Connecting…', 'status-disconnected');
|
setStatus('Connecting…', 'status-disconnected');
|
||||||
|
loadAllStudentsFromServer().catch(() => { /* ignore */ });
|
||||||
connectTeacher({
|
connectTeacher({
|
||||||
token,
|
token,
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
|
|
@ -329,6 +394,40 @@ function connectIfTeacher() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function suggestUsername() {
|
||||||
|
try {
|
||||||
|
const data = await teacherRequest('/api/teacher/students/suggest-username');
|
||||||
|
if (createUsernameInput) createUsernameInput.value = data.username || '';
|
||||||
|
setCreateStatus('');
|
||||||
|
} catch (err) {
|
||||||
|
setCreateStatus(err.message || 'Could not generate username', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStudentQuick() {
|
||||||
|
const username = (createUsernameInput?.value || '').trim();
|
||||||
|
createStudentBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await teacherRequest('/api/teacher/students', {
|
||||||
|
method: 'POST',
|
||||||
|
body: username ? { username } : {},
|
||||||
|
});
|
||||||
|
if (createUsernameInput) createUsernameInput.value = data.student?.username || '';
|
||||||
|
upsertStudent({
|
||||||
|
userId: data.student.id,
|
||||||
|
username: data.student.username,
|
||||||
|
state: null,
|
||||||
|
online: false,
|
||||||
|
});
|
||||||
|
updateCount();
|
||||||
|
setCreateStatus(`Created ${data.student.username} (password: ${data.defaultPassword})`);
|
||||||
|
} catch (err) {
|
||||||
|
setCreateStatus(err.message || 'Could not create student', true);
|
||||||
|
} finally {
|
||||||
|
createStudentBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openLoginModal() {
|
function openLoginModal() {
|
||||||
const overlay = document.getElementById('login-overlay');
|
const overlay = document.getElementById('login-overlay');
|
||||||
if (overlay) overlay.classList.remove('hidden');
|
if (overlay) overlay.classList.remove('hidden');
|
||||||
|
|
@ -343,6 +442,14 @@ onAuthChange(() => connectIfTeacher());
|
||||||
ensureHiddenWorkspace();
|
ensureHiddenWorkspace();
|
||||||
updateCount();
|
updateCount();
|
||||||
|
|
||||||
|
generateUsernameBtn?.addEventListener('click', () => {
|
||||||
|
suggestUsername().catch(() => { /* ignore */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
createStudentBtn?.addEventListener('click', () => {
|
||||||
|
createStudentQuick().catch(() => { /* ignore */ });
|
||||||
|
});
|
||||||
|
|
||||||
refreshMe()
|
refreshMe()
|
||||||
.then(() => connectIfTeacher())
|
.then(() => connectIfTeacher())
|
||||||
.catch(() => connectIfTeacher());
|
.catch(() => connectIfTeacher());
|
||||||
|
|
|
||||||
14
teacher.html
14
teacher.html
|
|
@ -22,9 +22,19 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="teacher-main">
|
<main id="teacher-main">
|
||||||
|
<section id="teacher-student-admin" class="teacher-student-admin">
|
||||||
|
<h3>Student Accounts</h3>
|
||||||
|
<p>Create student users quickly. Default password is the same as username.</p>
|
||||||
|
<div class="teacher-student-admin-row">
|
||||||
|
<input type="text" id="teacher-new-student-username" placeholder="username" />
|
||||||
|
<button id="teacher-generate-username" type="button">Generate</button>
|
||||||
|
<button id="teacher-create-student" type="button">Create student</button>
|
||||||
|
</div>
|
||||||
|
<div id="teacher-create-student-status" class="login-status"></div>
|
||||||
|
</section>
|
||||||
<div id="teacher-empty" class="teacher-empty hidden">
|
<div id="teacher-empty" class="teacher-empty hidden">
|
||||||
<p>No students online right now.</p>
|
<p>No students yet.</p>
|
||||||
<p class="teacher-empty-hint">Students show up here when they sign in on the main IDE.</p>
|
<p class="teacher-empty-hint">Create students above. They appear online when signed in.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="teacher-grid" class="teacher-grid"></div>
|
<div id="teacher-grid" class="teacher-grid"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue