載入題目中...
';
- return;
- }
-
- // Sort challenges by order
- const challenges = cachedChallenges.sort((a, b) => a.order - b.order);
-
- // Sort users by login time (or name)
- const sortedUsers = users.sort((a, b) => (a.joinedAt || 0) - (b.joinedAt || 0));
-
- let html = `
-
${displayAuthor}
${item.title}
@@ -2695,215 +1594,323 @@ if (navInstBtn) {
${cleanText(item.prompt)}
`;
- grid.appendChild(col);
-
- // If blurred, remove blur after animation purely for effect, or keep?
- // User intention "Hidden Name" usually means "Replaced by generic name".
- // The blur effect in toggle logic was transient.
- // If we want persistent anonymity, just "學員" is enough.
- // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
- // We should replicate that effect if we want consistency, or just skip blur on init.
- if (isAnonymous) {
- const el = col.querySelector('.comparison-author');
- setTimeout(() => el.classList.remove('blur-sm'), 300);
- }
- });
+ grid.appendChild(col);
+
+ // If blurred, remove blur after animation purely for effect, or keep?
+ // User intention "Hidden Name" usually means "Replaced by generic name".
+ // The blur effect in toggle logic was transient.
+ // If we want persistent anonymity, just "學員" is enough.
+ // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
+ // We should replicate that effect if we want consistency, or just skip blur on init.
+ if (isAnonymous) {
+ const el = col.querySelector('.comparison-author');
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
+ }
+ });
- document.getElementById('prompt-list-modal').classList.add('hidden');
- modal.classList.remove('hidden');
+ document.getElementById('prompt-list-modal').classList.add('hidden');
+ modal.classList.remove('hidden');
- // Init Canvas (Phase 3)
- setTimeout(setupCanvas, 100);
- };
+ // Init Canvas (Phase 3)
+ setTimeout(setupCanvas, 100);
+ };
+
+ window.closeComparison = () => {
+ document.getElementById('comparison-modal').classList.add('hidden');
+ clearCanvas();
+ };
- window.closeComparison = () => {
- document.getElementById('comparison-modal').classList.add('hidden');
- clearCanvas();
+ // --- Phase 3 & 6: Annotation Tools ---
+ let canvas, ctx;
+ let isDrawing = false;
+ let currentPenColor = '#ef4444'; // Red default
+ let currentLineWidth = 3;
+ let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
+
+ window.setupCanvas = () => {
+ canvas = document.getElementById('annotation-canvas');
+ const container = document.getElementById('comparison-container');
+ if (!canvas || !container) return;
+
+ ctx = canvas.getContext('2d');
+
+ // Resize
+ const resize = () => {
+ canvas.width = container.clientWidth;
+ canvas.height = container.clientHeight;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = currentPenColor;
+ ctx.lineWidth = currentLineWidth;
+ ctx.globalCompositeOperation = currentMode;
};
+ resize();
+ window.addEventListener('resize', resize);
+
+ // Init Size UI & Cursor
+ updateSizeBtnUI();
+ updateCursorStyle();
+
+ // Cursor Logic
+ const cursor = document.getElementById('tool-cursor');
+
+ canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
+ canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
+ canvas.addEventListener('mousemove', (e) => {
+ const { x, y } = getPos(e);
+ cursor.style.left = `${x}px`;
+ cursor.style.top = `${y}px`;
+ });
- // --- Phase 3 & 6: Annotation Tools ---
- let canvas, ctx;
- let isDrawing = false;
- let currentPenColor = '#ef4444'; // Red default
- let currentLineWidth = 3;
- let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
-
- window.setupCanvas = () => {
- canvas = document.getElementById('annotation-canvas');
- const container = document.getElementById('comparison-container');
- if (!canvas || !container) return;
-
- ctx = canvas.getContext('2d');
-
- // Resize
- const resize = () => {
- canvas.width = container.clientWidth;
- canvas.height = container.clientHeight;
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
- ctx.strokeStyle = currentPenColor;
- ctx.lineWidth = currentLineWidth;
- ctx.globalCompositeOperation = currentMode;
- };
- resize();
- window.addEventListener('resize', resize);
-
- // Init Size UI & Cursor
- updateSizeBtnUI();
- updateCursorStyle();
-
- // Cursor Logic
- const cursor = document.getElementById('tool-cursor');
-
- canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
- canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
- canvas.addEventListener('mousemove', (e) => {
- const { x, y } = getPos(e);
- cursor.style.left = `${x}px`;
- cursor.style.top = `${y}px`;
- });
+ // Drawing Events
+ const start = (e) => {
+ isDrawing = true;
+ ctx.beginPath();
- // Drawing Events
- const start = (e) => {
- isDrawing = true;
- ctx.beginPath();
-
- // Re-apply settings (state might change)
- ctx.globalCompositeOperation = currentMode;
- ctx.strokeStyle = currentPenColor;
- ctx.lineWidth = currentLineWidth;
-
- const { x, y } = getPos(e);
- ctx.moveTo(x, y);
- };
-
- const move = (e) => {
- if (!isDrawing) return;
- const { x, y } = getPos(e);
- ctx.lineTo(x, y);
- ctx.stroke();
- };
-
- const end = () => {
- isDrawing = false;
- };
-
- canvas.onmousedown = start;
- canvas.onmousemove = move;
- canvas.onmouseup = end;
- canvas.onmouseleave = end;
-
- // Touch support
- canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
- canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
- canvas.ontouchend = (e) => { e.preventDefault(); end(); };
- };
+ // Re-apply settings (state might change)
+ ctx.globalCompositeOperation = currentMode;
+ ctx.strokeStyle = currentPenColor;
+ ctx.lineWidth = currentLineWidth;
- function getPos(e) {
- const rect = canvas.getBoundingClientRect();
- return {
- x: e.clientX - rect.left,
- y: e.clientY - rect.top
- };
- }
+ const { x, y } = getPos(e);
+ ctx.moveTo(x, y);
+ };
- // Unified Tool Handler
- window.setPenTool = (tool, color, btn) => {
- // UI Update
- document.querySelectorAll('.annotation-tool').forEach(b => {
- b.classList.remove('ring-white');
- b.classList.add('ring-transparent');
- });
- btn.classList.remove('ring-transparent');
- btn.classList.add('ring-white');
-
- if (tool === 'eraser') {
- currentMode = 'destination-out';
- // Force larger eraser size (e.g., 3x current size or fixed large)
- // We'll multiply current selected size by 4 for better UX
- const multiplier = 4;
- // Store original explicitly if needed, but currentLineWidth is global.
- // We should dynamically adjust context lineWidth during draw, or just hack it here.
- // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
- // Better: Update cursor style only? No, actual draw needs it.
- // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
- // Simpler: Just change it. When user clicks size button, it resets.
- // But if user clicks Pen back? We need to restore.
- // Let's rely on setPenTool being called with color.
- // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
- // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
- // We need to change how draw() uses the width.
- // BUT, since we don't want to touch draw() deep inside:
- // We will hijack currentLineWidth.
- if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
- currentLineWidth = window.savedPenWidth * 4;
- } else {
- currentMode = 'source-over';
- currentPenColor = color;
- // Restore pen width
- if (window.savedPenWidth) {
- currentLineWidth = window.savedPenWidth;
- window.savedPenWidth = null;
- }
- }
- updateCursorStyle();
+ const move = (e) => {
+ if (!isDrawing) return;
+ const { x, y } = getPos(e);
+ ctx.lineTo(x, y);
+ ctx.stroke();
};
- // Size Handler
- window.setPenSize = (size, btn) => {
- currentLineWidth = size;
- updateSizeBtnUI();
- updateCursorStyle();
+ const end = () => {
+ isDrawing = false;
};
- function updateCursorStyle() {
- const cursor = document.getElementById('tool-cursor');
- if (!cursor) return;
+ canvas.onmousedown = start;
+ canvas.onmousemove = move;
+ canvas.onmouseup = end;
+ canvas.onmouseleave = end;
- // Size
- cursor.style.width = `${currentLineWidth}px`;
- cursor.style.height = `${currentLineWidth}px`;
+ // Touch support
+ canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
+ canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
+ canvas.ontouchend = (e) => { e.preventDefault(); end(); };
+ };
- // Color
- if (currentMode === 'destination-out') {
- // Eraser: White solid
- cursor.style.backgroundColor = 'white';
- cursor.style.borderColor = '#999';
- } else {
- // Pen: Tool color
- cursor.style.backgroundColor = currentPenColor;
- cursor.style.borderColor = 'rgba(255,255,255,0.8)';
+ function getPos(e) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top
+ };
+ }
+
+ // Unified Tool Handler
+ window.setPenTool = (tool, color, btn) => {
+ // UI Update
+ document.querySelectorAll('.annotation-tool').forEach(b => {
+ b.classList.remove('ring-white');
+ b.classList.add('ring-transparent');
+ });
+ btn.classList.remove('ring-transparent');
+ btn.classList.add('ring-white');
+
+ if (tool === 'eraser') {
+ currentMode = 'destination-out';
+ // Force larger eraser size (e.g., 3x current size or fixed large)
+ // We'll multiply current selected size by 4 for better UX
+ const multiplier = 4;
+ // Store original explicitly if needed, but currentLineWidth is global.
+ // We should dynamically adjust context lineWidth during draw, or just hack it here.
+ // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
+ // Better: Update cursor style only? No, actual draw needs it.
+ // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
+ // Simpler: Just change it. When user clicks size button, it resets.
+ // But if user clicks Pen back? We need to restore.
+ // Let's rely on setPenTool being called with color.
+ // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
+ // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
+ // We need to change how draw() uses the width.
+ // BUT, since we don't want to touch draw() deep inside:
+ // We will hijack currentLineWidth.
+ if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
+ currentLineWidth = window.savedPenWidth * 4;
+ } else {
+ currentMode = 'source-over';
+ currentPenColor = color;
+ // Restore pen width
+ if (window.savedPenWidth) {
+ currentLineWidth = window.savedPenWidth;
+ window.savedPenWidth = null;
}
}
+ updateCursorStyle();
+ };
- function updateSizeBtnUI() {
- document.querySelectorAll('.size-btn').forEach(b => {
- if (parseInt(b.dataset.size) === currentLineWidth) {
- b.classList.add('bg-gray-600', 'text-white');
- b.classList.remove('text-gray-400', 'hover:bg-gray-700');
- } else {
- b.classList.remove('bg-gray-600', 'text-white');
- b.classList.add('text-gray-400', 'hover:bg-gray-700');
- }
- });
+ // Size Handler
+ window.setPenSize = (size, btn) => {
+ currentLineWidth = size;
+ updateSizeBtnUI();
+ updateCursorStyle();
+ };
+
+ function updateCursorStyle() {
+ const cursor = document.getElementById('tool-cursor');
+ if (!cursor) return;
+
+ // Size
+ cursor.style.width = `${currentLineWidth}px`;
+ cursor.style.height = `${currentLineWidth}px`;
+
+ // Color
+ if (currentMode === 'destination-out') {
+ // Eraser: White solid
+ cursor.style.backgroundColor = 'white';
+ cursor.style.borderColor = '#999';
+ } else {
+ // Pen: Tool color
+ cursor.style.backgroundColor = currentPenColor;
+ cursor.style.borderColor = 'rgba(255,255,255,0.8)';
}
+ }
- window.clearCanvas = () => {
- if (canvas && ctx) {
- ctx.clearRect(0, 0, canvas.width, canvas.height);
+ function updateSizeBtnUI() {
+ document.querySelectorAll('.size-btn').forEach(b => {
+ if (parseInt(b.dataset.size) === currentLineWidth) {
+ b.classList.add('bg-gray-600', 'text-white');
+ b.classList.remove('text-gray-400', 'hover:bg-gray-700');
+ } else {
+ b.classList.remove('bg-gray-600', 'text-white');
+ b.classList.add('text-gray-400', 'hover:bg-gray-700');
}
- };
+ });
+ }
+ window.clearCanvas = () => {
+ if (canvas && ctx) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ }
+ };
+ }
+ /**
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
+ */
+ function renderTransposedHeatmap(students) {
+ const thead = document.getElementById('heatmap-header');
+ const tbody = document.getElementById('heatmap-body');
- // Bind to window
- window.renderTransposedHeatmap = renderTransposedHeatmap;
- window.showBroadcastModal = showBroadcastModal;
+ if (students.length === 0) {
+ thead.innerHTML = '
等待資料... | ';
+ tbody.innerHTML = '
| 尚無學員加入 |
';
+ return;
+ }
+
+ // 1. Render Header (Students)
+ // Sticky Top for Header Row
+ // Sticky Left for the first cell ("Challenge/Student")
+ let headerHtml = `
+
+
+ 題目
+ 學員 (${students.length})
+
+ |
+ `;
+
+ students.forEach(student => {
+ headerHtml += `
+
+
+
+ ${student.nickname[0]}
+
+
+
+
+
+
+
+
+ |
+ `;
+ });
+ thead.innerHTML = headerHtml;
+
+ // 2. Render Body (Challenges as Rows)
+ if (cachedChallenges.length === 0) {
+ tbody.innerHTML = '
| 沒有題目資料 |
';
+ return;
}
- }
-}
+ tbody.innerHTML = cachedChallenges.map((c, index) => {
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
+ const color = colors[c.level] || 'gray';
+
+ // Build Row Cells (One per student)
+ const rowCells = students.map(student => {
+ const p = student.progress?.[c.id];
+ let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
+ let content = '';
+ let action = '';
+
+ if (p) {
+ if (p.status === 'completed') {
+ statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
+ content = '✅';
+ // Action removed: Moved to prompt list view
+ action = `title="完成 - 請點擊標題查看詳情"`;
+ } else if (p.status === 'started') {
+ // Check stuck
+ const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
+ const now = new Date();
+ const diffMins = (now - startedAt) / 1000 / 60;
+
+ if (diffMins > 5) {
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
+ content = '🆘';
+ } else {
+ statusClass = 'bg-blue-600/20 border-blue-500';
+ content = '🔵';
+ }
+ }
+ }
+ return `
+
+
+ ${content}
+
+ |
+ `;
+ }).join('');
+
+ // Row Header (Challenge Title)
+ return `
+
+
+
+
+ ${c.level}
+
+
+
+
+
+ |
+ ${rowCells}
+
+ `;
+ }).join('');
+ }
+}