Lashtw commited on
Commit
61bacd9
·
verified ·
1 Parent(s): e68af0c

Upload 10 files

Browse files
Files changed (1) hide show
  1. src/views/InstructorView.js +348 -479
src/views/InstructorView.js CHANGED
@@ -2187,223 +2187,92 @@ export function setupInstructorEvents() {
2187
  aiAnalyzeBtn.classList.remove('animate-pulse');
2188
  }
2189
  });
 
 
 
 
 
 
2190
 
2191
- if (submissions.length === 0) throw new Error("無有效作品");
2192
-
2193
- // Get Challenge Description
2194
- const challenge = cachedChallenges.find(c => c.id === window.currentViewingChallengeId);
2195
- const desc = challenge ? challenge.description : '';
2196
-
2197
- // Dynamic Import
2198
- const { initGemini, evaluatePrompts } = await import("../services/gemini.js");
2199
- // Ensure AI is initialized with the stored key
2200
- const geminiKey = localStorage.getItem('vibecoding_gemini_key');
2201
- await initGemini(geminiKey);
2202
- const results = await evaluatePrompts(submissions, desc);
2203
-
2204
- // Apply Results to UI (Badges)
2205
- // Results: { rough: [ids], brave: [ids], ... }
2206
-
2207
- // Map categories to badges
2208
- const BadgeMap = {
2209
- rough: { icon: '💎', color: 'text-blue-400', label: '原石' },
2210
- precise: { icon: '🎯', color: 'text-green-400', label: '精確' },
2211
- gentle: { icon: '🤝', color: 'text-pink-400', label: '有禮' },
2212
- creative: { icon: '🦄', color: 'text-purple-400', label: '創意' },
2213
- spam: { icon: '🗑️', color: 'text-gray-500', label: '無效' },
2214
- parrot: { icon: '🦜', color: 'text-yellow-400', label: '鸚鵡' },
2215
- cheater: { icon: '⚠️', color: 'text-red-500', label: '疑似作弊' }
2216
- };
2217
-
2218
- // Iterate cards in DOM
2219
- const container = document.getElementById('prompt-list-container');
2220
- const cards = container.querySelectorAll('.group'); // Cards have 'group' class
2221
-
2222
- // Reset previous badges
2223
- document.querySelectorAll('.ai-badge').forEach(e => e.remove());
2224
-
2225
- // Build summary for display
2226
- let summaryHtml = '<div class="space-y-3">';
2227
- const categoryOrder = ['creative', 'precise', 'rough', 'gentle', 'parrot', 'spam'];
2228
-
2229
- // Get student names for display
2230
- const getStudentName = (userId) => {
2231
- const student = currentStudents.find(s => s.id === userId);
2232
- return student?.nickname || student?.displayName || userId.slice(0, 6);
2233
- };
2234
-
2235
- categoryOrder.forEach(cat => {
2236
- const idsRaw = results[cat];
2237
- if (!idsRaw || idsRaw.length === 0) return;
2238
-
2239
- // Clean IDs (remove "ID_" prefix if present)
2240
- const ids = idsRaw.map(id => id.replace(/^ID_/, ''));
2241
-
2242
- const badge = BadgeMap[cat];
2243
- if (!badge) return;
2244
-
2245
- const names = ids.map(id => getStudentName(id)).join(', ');
2246
- summaryHtml += `
2247
- <div class="p-3 rounded-lg bg-gray-800 border border-gray-700">
2248
- <div class="flex items-center gap-2 mb-1">
2249
- <span class="text-xl">${badge.icon}</span>
2250
- <span class="${badge.color} font-bold">${badge.label}</span>
2251
- <span class="text-gray-400 text-sm">(${ids.length}人)</span>
2252
- </div>
2253
- <div class="text-gray-300 text-sm">${names}</div>
2254
- </div>
2255
- `;
2256
- });
2257
- summaryHtml += '</div>';
2258
-
2259
- // Apply badges to cards
2260
- cards.forEach(card => {
2261
- const checkbox = card.querySelector('input[type="checkbox"]');
2262
- if (!checkbox) return;
2263
-
2264
- // data-id is "studentId_challengeId"
2265
- const fullId = checkbox.dataset.id;
2266
- const studentId = fullId.split('_')[0]; // Simple split
2267
-
2268
- // Find which category this student falls into
2269
- let matchedCategory = null;
2270
- for (const [cat, idsRaw] of Object.entries(results)) {
2271
- const ids = idsRaw?.map(id => id.replace(/^ID_/, '')) || [];
2272
- if (ids.includes(studentId)) {
2273
- matchedCategory = cat;
2274
- break;
2275
- }
2276
  }
 
 
2277
 
2278
- if (matchedCategory && BadgeMap[matchedCategory]) {
2279
- const badge = BadgeMap[matchedCategory];
2280
- const badgeEl = document.createElement('div');
2281
- badgeEl.className = `ai-badge absolute top-2 right-12 px-2 py-0.5 rounded border border-gray-600 bg-gray-900 ${badge.color} text-xs font-bold flex items-center shadow-lg transform scale-100 animate-pulse`;
2282
- badgeEl.innerHTML = `${badge.icon} ${badge.label}`;
2283
- card.style.position = 'relative'; // Ensure absolute positioning works
2284
- card.appendChild(badgeEl);
2285
-
2286
- // If spam/parrot, border red
2287
- if (matchedCategory === 'spam' || matchedCategory === 'parrot') {
2288
- card.classList.add('border-red-500');
2289
- }
 
 
 
 
 
2290
  }
2291
  });
 
2292
 
2293
- // Show summary modal
2294
- const existingSummaryModal = document.getElementById('ai-summary-modal');
2295
- if (existingSummaryModal) existingSummaryModal.remove();
2296
-
2297
- const summaryModal = document.createElement('div');
2298
- summaryModal.id = 'ai-summary-modal';
2299
- summaryModal.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-[100] p-4';
2300
- summaryModal.innerHTML = `
2301
- <div class="bg-gray-900 border border-cyan-500/50 rounded-xl p-6 max-w-md w-full shadow-2xl">
2302
- <h3 class="text-xl font-bold text-cyan-400 mb-4 flex items-center gap-2">
2303
- ✨ AI 分析結果
2304
- </h3>
2305
- <div class="max-h-[60vh] overflow-y-auto">
2306
- ${summaryHtml || '<p class="text-gray-400">沒有特別的分類結果</p>'}
2307
- </div>
2308
- <button id="close-ai-summary" class="mt-4 w-full py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg font-bold transition-colors">
2309
- 確定
2310
- </button>
2311
- </div>
2312
- `;
2313
- document.body.appendChild(summaryModal);
2314
-
2315
- document.getElementById('close-ai-summary').onclick = () => summaryModal.remove();
2316
-
2317
-
2318
-
2319
- // Direct Heatmap AI Analysis Link
2320
- window.analyzeChallenge = (challengeId, challengeTitle) => {
2321
- if (!localStorage.getItem('vibecoding_gemini_key')) {
2322
- alert("請先設定 Gemini API Key");
2323
- return;
2324
- }
2325
- // 1. Open the list
2326
- window.openPromptList('challenge', challengeId, challengeTitle);
2327
-
2328
- // 2. Trigger analysis logic automatically after slight delay (to ensure DOM render)
2329
- setTimeout(() => {
2330
- const btn = document.getElementById('btn-ai-analyze');
2331
- if (btn && !btn.disabled) {
2332
- btn.click();
2333
- } else {
2334
- console.warn("AI Analyze button not found or disabled");
2335
- }
2336
- }, 300);
2337
- };
2338
-
2339
- let isAnonymous = false;
2340
-
2341
- window.toggleAnonymous = (btn) => {
2342
- isAnonymous = !isAnonymous;
2343
- btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
2344
- btn.classList.toggle('bg-gray-700');
2345
- btn.classList.toggle('bg-purple-700');
2346
-
2347
- // Update DOM
2348
- document.querySelectorAll('.comparison-author').forEach(el => {
2349
- if (isAnonymous) {
2350
- el.dataset.original = el.textContent;
2351
- el.textContent = '學員';
2352
- el.classList.add('blur-sm'); // Optional Effect
2353
- setTimeout(() => el.classList.remove('blur-sm'), 300);
2354
- } else {
2355
- if (el.dataset.original) el.textContent = el.dataset.original;
2356
- }
2357
- });
2358
- };
2359
-
2360
- window.openComparisonView = (items, initialAnonymous = false) => {
2361
- const modal = document.getElementById('comparison-modal');
2362
- const grid = document.getElementById('comparison-grid');
2363
 
2364
- // Apply Anonymous State
2365
- isAnonymous = initialAnonymous;
2366
- const anonBtn = document.getElementById('btn-anonymous-toggle');
2367
 
2368
- // Update Toggle UI to match state
2369
- if (anonBtn) {
2370
- if (isAnonymous) {
2371
- anonBtn.textContent = '🙈 顯示姓名';
2372
- anonBtn.classList.add('bg-purple-700');
2373
- anonBtn.classList.remove('bg-gray-700');
2374
- } else {
2375
- anonBtn.textContent = '👀 隱藏姓名';
2376
- anonBtn.classList.remove('bg-purple-700');
2377
- anonBtn.classList.add('bg-gray-700');
2378
- }
2379
  }
 
2380
 
2381
- // Setup Grid Rows (Vertical Stacking)
2382
- let rowClass = 'grid-rows-1';
2383
- if (items.length === 2) rowClass = 'grid-rows-2';
2384
- if (items.length === 3) rowClass = 'grid-rows-3';
2385
-
2386
- grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
2387
- grid.innerHTML = '';
2388
-
2389
- items.forEach(item => {
2390
- const col = document.createElement('div');
2391
- // Check overflow-hidden to keep it contained, use flex-row for compact header + content
2392
- col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
2393
-
2394
- // Logic for anonymous
2395
- let displayAuthor = item.author;
2396
- let blurClass = '';
2397
-
2398
- if (isAnonymous) {
2399
- displayAuthor = '學員';
2400
- blurClass = 'blur-sm'; // Initial blur
2401
- // Auto remove blur after delay if needed, or keep it?
2402
- // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
2403
- // The toggle logic uses dataset.original. We need to set it here too.
2404
- }
2405
 
2406
- col.innerHTML = `
2407
  <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center">
2408
  <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3>
2409
  <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p>
@@ -2412,222 +2281,222 @@ export function setupInstructorEvents() {
2412
  <!-- Prompt Content: Larger Text (text-4xl) -->
2413
  <div class="flex-1 overflow-y-auto font-mono text-green-300 text-3xl leading-relaxed whitespace-pre-wrap p-2 hover:bg-white/5 transition-colors rounded custom-scrollbar text-left" style="text-align: left !important;">${cleanText(item.prompt)}</div>
2414
  `;
2415
- grid.appendChild(col);
2416
-
2417
- // If blurred, remove blur after animation purely for effect, or keep?
2418
- // User intention "Hidden Name" usually means "Replaced by generic name".
2419
- // The blur effect in toggle logic was transient.
2420
- // If we want persistent anonymity, just "學員" is enough.
2421
- // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
2422
- // We should replicate that effect if we want consistency, or just skip blur on init.
2423
- if (isAnonymous) {
2424
- const el = col.querySelector('.comparison-author');
2425
- setTimeout(() => el.classList.remove('blur-sm'), 300);
2426
- }
2427
- });
 
 
 
2428
 
2429
- document.getElementById('prompt-list-modal').classList.add('hidden');
2430
- modal.classList.remove('hidden');
 
2431
 
2432
- // Init Canvas (Phase 3)
2433
- setTimeout(setupCanvas, 100);
2434
- };
 
2435
 
2436
- window.closeComparison = () => {
2437
- document.getElementById('comparison-modal').classList.add('hidden');
2438
- clearCanvas();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2439
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2440
 
2441
- // --- Phase 3 & 6: Annotation Tools ---
2442
- let canvas, ctx;
2443
- let isDrawing = false;
2444
- let currentPenColor = '#ef4444'; // Red default
2445
- let currentLineWidth = 3;
2446
- let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
2447
-
2448
- window.setupCanvas = () => {
2449
- canvas = document.getElementById('annotation-canvas');
2450
- const container = document.getElementById('comparison-container');
2451
- if (!canvas || !container) return;
2452
-
2453
- ctx = canvas.getContext('2d');
2454
-
2455
- // Resize
2456
- const resize = () => {
2457
- canvas.width = container.clientWidth;
2458
- canvas.height = container.clientHeight;
2459
- ctx.lineCap = 'round';
2460
- ctx.lineJoin = 'round';
2461
- ctx.strokeStyle = currentPenColor;
2462
- ctx.lineWidth = currentLineWidth;
2463
- ctx.globalCompositeOperation = currentMode;
2464
- };
2465
- resize();
2466
- window.addEventListener('resize', resize);
2467
-
2468
- // Init Size UI & Cursor
2469
- updateSizeBtnUI();
2470
- updateCursorStyle();
2471
-
2472
- // Cursor Logic
2473
- const cursor = document.getElementById('tool-cursor');
2474
-
2475
- canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
2476
- canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
2477
- canvas.addEventListener('mousemove', (e) => {
2478
- const { x, y } = getPos(e);
2479
- cursor.style.left = `${x}px`;
2480
- cursor.style.top = `${y}px`;
2481
- });
2482
 
2483
- // Drawing Events
2484
- const start = (e) => {
2485
- isDrawing = true;
2486
- ctx.beginPath();
2487
-
2488
- // Re-apply settings (state might change)
2489
- ctx.globalCompositeOperation = currentMode;
2490
- ctx.strokeStyle = currentPenColor;
2491
- ctx.lineWidth = currentLineWidth;
2492
-
2493
- const { x, y } = getPos(e);
2494
- ctx.moveTo(x, y);
2495
- };
2496
-
2497
- const move = (e) => {
2498
- if (!isDrawing) return;
2499
- const { x, y } = getPos(e);
2500
- ctx.lineTo(x, y);
2501
- ctx.stroke();
2502
- };
2503
-
2504
- const end = () => {
2505
- isDrawing = false;
2506
- };
2507
-
2508
- canvas.onmousedown = start;
2509
- canvas.onmousemove = move;
2510
- canvas.onmouseup = end;
2511
- canvas.onmouseleave = end;
2512
-
2513
- // Touch support
2514
- canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
2515
- canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
2516
- canvas.ontouchend = (e) => { e.preventDefault(); end(); };
2517
- };
2518
 
2519
- function getPos(e) {
2520
- const rect = canvas.getBoundingClientRect();
2521
- return {
2522
- x: e.clientX - rect.left,
2523
- y: e.clientY - rect.top
2524
- };
2525
- }
2526
 
2527
- // Unified Tool Handler
2528
- window.setPenTool = (tool, color, btn) => {
2529
- // UI Update
2530
- document.querySelectorAll('.annotation-tool').forEach(b => {
2531
- b.classList.remove('ring-white');
2532
- b.classList.add('ring-transparent');
2533
- });
2534
- btn.classList.remove('ring-transparent');
2535
- btn.classList.add('ring-white');
2536
-
2537
- if (tool === 'eraser') {
2538
- currentMode = 'destination-out';
2539
- // Force larger eraser size (e.g., 3x current size or fixed large)
2540
- // We'll multiply current selected size by 4 for better UX
2541
- const multiplier = 4;
2542
- // Store original explicitly if needed, but currentLineWidth is global.
2543
- // We should dynamically adjust context lineWidth during draw, or just hack it here.
2544
- // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
2545
- // Better: Update cursor style only? No, actual draw needs it.
2546
- // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
2547
- // Simpler: Just change it. When user clicks size button, it resets.
2548
- // But if user clicks Pen back? We need to restore.
2549
- // Let's rely on setPenTool being called with color.
2550
- // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
2551
- // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
2552
- // We need to change how draw() uses the width.
2553
- // BUT, since we don't want to touch draw() deep inside:
2554
- // We will hijack currentLineWidth.
2555
- if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
2556
- currentLineWidth = window.savedPenWidth * 4;
2557
- } else {
2558
- currentMode = 'source-over';
2559
- currentPenColor = color;
2560
- // Restore pen width
2561
- if (window.savedPenWidth) {
2562
- currentLineWidth = window.savedPenWidth;
2563
- window.savedPenWidth = null;
2564
- }
2565
- }
2566
- updateCursorStyle();
2567
  };
2568
 
2569
- // Size Handler
2570
- window.setPenSize = (size, btn) => {
2571
- currentLineWidth = size;
2572
- updateSizeBtnUI();
2573
- updateCursorStyle();
2574
  };
2575
 
2576
- function updateCursorStyle() {
2577
- const cursor = document.getElementById('tool-cursor');
2578
- if (!cursor) return;
 
2579
 
2580
- // Size
2581
- cursor.style.width = `${currentLineWidth}px`;
2582
- cursor.style.height = `${currentLineWidth}px`;
 
 
2583
 
2584
- // Color
2585
- if (currentMode === 'destination-out') {
2586
- // Eraser: White solid
2587
- cursor.style.backgroundColor = 'white';
2588
- cursor.style.borderColor = '#999';
2589
- } else {
2590
- // Pen: Tool color
2591
- cursor.style.backgroundColor = currentPenColor;
2592
- cursor.style.borderColor = 'rgba(255,255,255,0.8)';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2593
  }
2594
  }
 
 
2595
 
2596
- function updateSizeBtnUI() {
2597
- document.querySelectorAll('.size-btn').forEach(b => {
2598
- if (parseInt(b.dataset.size) === currentLineWidth) {
2599
- b.classList.add('bg-gray-600', 'text-white');
2600
- b.classList.remove('text-gray-400', 'hover:bg-gray-700');
2601
- } else {
2602
- b.classList.remove('bg-gray-600', 'text-white');
2603
- b.classList.add('text-gray-400', 'hover:bg-gray-700');
2604
- }
2605
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2606
  }
 
2607
 
2608
- window.clearCanvas = () => {
2609
- if (canvas && ctx) {
2610
- ctx.clearRect(0, 0, canvas.width, canvas.height);
 
 
 
 
 
2611
  }
2612
- };
 
2613
 
2614
- /**
2615
- * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
2616
- */
2617
- function renderTransposedHeatmap(students) {
2618
- const thead = document.getElementById('heatmap-header');
2619
- const tbody = document.getElementById('heatmap-body');
2620
 
2621
- if (students.length === 0) {
2622
- thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
2623
- tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
2624
- return;
2625
- }
 
 
 
 
 
 
 
2626
 
2627
- // 1. Render Header (Students)
2628
- // Sticky Top for Header Row
2629
- // Sticky Left for the first cell ("Challenge/Student")
2630
- let headerHtml = `
2631
  <th class="p-3 text-left sticky left-0 top-0 bg-gray-800 z-30 border-b border-gray-600 min-w-[200px] border-r border-gray-700 shadow-md">
2632
  <div class="flex justify-between items-end">
2633
  <span class="text-sm text-gray-400">題目</span>
@@ -2636,8 +2505,8 @@ export function setupInstructorEvents() {
2636
  </th>
2637
  `;
2638
 
2639
- students.forEach(student => {
2640
- headerHtml += `
2641
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
2642
  <div class="flex flex-col items-center space-y-2 py-2">
2643
  <div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-xs font-bold text-white uppercase border border-gray-500 shadow-sm relative">
@@ -2656,59 +2525,59 @@ export function setupInstructorEvents() {
2656
  </div>
2657
  </th>
2658
  `;
2659
- });
2660
- thead.innerHTML = headerHtml;
2661
 
2662
- // 2. Render Body (Challenges as Rows)
2663
- if (cachedChallenges.length === 0) {
2664
- tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
2665
- return;
2666
- }
2667
 
2668
- tbody.innerHTML = cachedChallenges.map((c, index) => {
2669
- const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
2670
- const color = colors[c.level] || 'gray';
2671
-
2672
- // Build Row Cells (One per student)
2673
- const rowCells = students.map(student => {
2674
- const p = student.progress?.[c.id];
2675
- let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
2676
- let content = '';
2677
- let action = '';
2678
-
2679
- if (p) {
2680
- if (p.status === 'completed') {
2681
- statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]';
2682
- content = '✅';
2683
- // Action restored: Allow direct click to open detailed view
2684
- action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`;
2685
- } else if (p.status === 'started') {
2686
- // Check stuck
2687
- const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
2688
- const now = new Date();
2689
- const diffMins = (now - startedAt) / 1000 / 60;
2690
-
2691
- if (diffMins > 5) {
2692
- statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
2693
- content = '🆘';
2694
- } else {
2695
- statusClass = 'bg-blue-600/20 border-blue-500';
2696
- content = '🔵';
2697
- }
2698
  }
2699
  }
 
2700
 
2701
- return `
2702
  <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
2703
  <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
2704
  ${content}
2705
  </div>
2706
  </td>
2707
  `;
2708
- }).join('');
2709
 
2710
- // Row Header (Challenge Title)
2711
- return `
2712
  <tr class="hover:bg-gray-800/50 transition-colors">
2713
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
2714
  <div class="flex items-center justify-between">
@@ -2728,45 +2597,45 @@ export function setupInstructorEvents() {
2728
  ${rowCells}
2729
  </tr>
2730
  `;
2731
- }).join('');
2732
- }
2733
 
2734
- // Global scope for HTML access
2735
- function showBroadcastModal(userId, challengeId) {
2736
- const student = currentStudents.find(s => s.id === userId);
2737
- if (!student) return;
2738
-
2739
- const p = student.progress?.[challengeId];
2740
- if (!p) return;
2741
-
2742
- const challenge = cachedChallenges.find(c => c.id === challengeId);
2743
- const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
2744
-
2745
- const modal = document.getElementById('broadcast-modal');
2746
- const content = document.getElementById('broadcast-content');
2747
-
2748
- document.getElementById('broadcast-avatar').textContent = student.nickname[0];
2749
- document.getElementById('broadcast-author').textContent = student.nickname;
2750
- document.getElementById('broadcast-challenge').textContent = title;
2751
- // content is already just text, but let's be safe
2752
- const rawText = p.prompt || p.code || '';
2753
- const isCode = !p.prompt && !!p.code; // If only code exists, treat as code
2754
- document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode);
2755
- document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit
2756
-
2757
- // Store IDs for actions
2758
- modal.dataset.userId = userId;
2759
- modal.classList.remove('hidden');
2760
- // Animation trigger
2761
- setTimeout(() => {
2762
- content.classList.remove('scale-95', 'opacity-0');
2763
- content.classList.add('opacity-100', 'scale-100');
2764
- }, 10);
2765
- }
2766
 
2767
- // Bind to window
2768
- window.renderTransposedHeatmap = renderTransposedHeatmap;
2769
- window.showBroadcastModal = showBroadcastModal;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2770
  }
2771
 
 
 
 
 
 
2772
 
 
2187
  aiAnalyzeBtn.classList.remove('animate-pulse');
2188
  }
2189
  });
2190
+ if (!localStorage.getItem('vibecoding_gemini_key')) {
2191
+ alert("請先設定 Gemini API Key");
2192
+ return;
2193
+ }
2194
+ // 1. Open the list
2195
+ window.openPromptList('challenge', challengeId, challengeTitle);
2196
 
2197
+ // 2. Trigger analysis logic automatically after slight delay (to ensure DOM render)
2198
+ setTimeout(() => {
2199
+ const btn = document.getElementById('btn-ai-analyze');
2200
+ if (btn && !btn.disabled) {
2201
+ btn.click();
2202
+ } else {
2203
+ console.warn("AI Analyze button not found or disabled");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2204
  }
2205
+ }, 300);
2206
+ };
2207
 
2208
+ let isAnonymous = false;
2209
+
2210
+ window.toggleAnonymous = (btn) => {
2211
+ isAnonymous = !isAnonymous;
2212
+ btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
2213
+ btn.classList.toggle('bg-gray-700');
2214
+ btn.classList.toggle('bg-purple-700');
2215
+
2216
+ // Update DOM
2217
+ document.querySelectorAll('.comparison-author').forEach(el => {
2218
+ if (isAnonymous) {
2219
+ el.dataset.original = el.textContent;
2220
+ el.textContent = '學員';
2221
+ el.classList.add('blur-sm'); // Optional Effect
2222
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
2223
+ } else {
2224
+ if (el.dataset.original) el.textContent = el.dataset.original;
2225
  }
2226
  });
2227
+ };
2228
 
2229
+ window.openComparisonView = (items, initialAnonymous = false) => {
2230
+ const modal = document.getElementById('comparison-modal');
2231
+ const grid = document.getElementById('comparison-grid');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2232
 
2233
+ // Apply Anonymous State
2234
+ isAnonymous = initialAnonymous;
2235
+ const anonBtn = document.getElementById('btn-anonymous-toggle');
2236
 
2237
+ // Update Toggle UI to match state
2238
+ if (anonBtn) {
2239
+ if (isAnonymous) {
2240
+ anonBtn.textContent = '🙈 顯示姓名';
2241
+ anonBtn.classList.add('bg-purple-700');
2242
+ anonBtn.classList.remove('bg-gray-700');
2243
+ } else {
2244
+ anonBtn.textContent = '👀 隱藏姓名';
2245
+ anonBtn.classList.remove('bg-purple-700');
2246
+ anonBtn.classList.add('bg-gray-700');
 
2247
  }
2248
+ }
2249
 
2250
+ // Setup Grid Rows (Vertical Stacking)
2251
+ let rowClass = 'grid-rows-1';
2252
+ if (items.length === 2) rowClass = 'grid-rows-2';
2253
+ if (items.length === 3) rowClass = 'grid-rows-3';
2254
+
2255
+ grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
2256
+ grid.innerHTML = '';
2257
+
2258
+ items.forEach(item => {
2259
+ const col = document.createElement('div');
2260
+ // Check overflow-hidden to keep it contained, use flex-row for compact header + content
2261
+ col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
2262
+
2263
+ // Logic for anonymous
2264
+ let displayAuthor = item.author;
2265
+ let blurClass = '';
2266
+
2267
+ if (isAnonymous) {
2268
+ displayAuthor = '學員';
2269
+ blurClass = 'blur-sm'; // Initial blur
2270
+ // Auto remove blur after delay if needed, or keep it?
2271
+ // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
2272
+ // The toggle logic uses dataset.original. We need to set it here too.
2273
+ }
2274
 
2275
+ col.innerHTML = `
2276
  <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center">
2277
  <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3>
2278
  <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p>
 
2281
  <!-- Prompt Content: Larger Text (text-4xl) -->
2282
  <div class="flex-1 overflow-y-auto font-mono text-green-300 text-3xl leading-relaxed whitespace-pre-wrap p-2 hover:bg-white/5 transition-colors rounded custom-scrollbar text-left" style="text-align: left !important;">${cleanText(item.prompt)}</div>
2283
  `;
2284
+ grid.appendChild(col);
2285
+
2286
+ // If blurred, remove blur after animation purely for effect, or keep?
2287
+ // User intention "Hidden Name" usually means "Replaced by generic name".
2288
+ // The blur effect in toggle logic was transient.
2289
+ // If we want persistent anonymity, just "學員" is enough.
2290
+ // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
2291
+ // We should replicate that effect if we want consistency, or just skip blur on init.
2292
+ if (isAnonymous) {
2293
+ const el = col.querySelector('.comparison-author');
2294
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
2295
+ }
2296
+ });
2297
+
2298
+ document.getElementById('prompt-list-modal').classList.add('hidden');
2299
+ modal.classList.remove('hidden');
2300
 
2301
+ // Init Canvas (Phase 3)
2302
+ setTimeout(setupCanvas, 100);
2303
+ };
2304
 
2305
+ window.closeComparison = () => {
2306
+ document.getElementById('comparison-modal').classList.add('hidden');
2307
+ clearCanvas();
2308
+ };
2309
 
2310
+ // --- Phase 3 & 6: Annotation Tools ---
2311
+ let canvas, ctx;
2312
+ let isDrawing = false;
2313
+ let currentPenColor = '#ef4444'; // Red default
2314
+ let currentLineWidth = 3;
2315
+ let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
2316
+
2317
+ window.setupCanvas = () => {
2318
+ canvas = document.getElementById('annotation-canvas');
2319
+ const container = document.getElementById('comparison-container');
2320
+ if (!canvas || !container) return;
2321
+
2322
+ ctx = canvas.getContext('2d');
2323
+
2324
+ // Resize
2325
+ const resize = () => {
2326
+ canvas.width = container.clientWidth;
2327
+ canvas.height = container.clientHeight;
2328
+ ctx.lineCap = 'round';
2329
+ ctx.lineJoin = 'round';
2330
+ ctx.strokeStyle = currentPenColor;
2331
+ ctx.lineWidth = currentLineWidth;
2332
+ ctx.globalCompositeOperation = currentMode;
2333
  };
2334
+ resize();
2335
+ window.addEventListener('resize', resize);
2336
+
2337
+ // Init Size UI & Cursor
2338
+ updateSizeBtnUI();
2339
+ updateCursorStyle();
2340
+
2341
+ // Cursor Logic
2342
+ const cursor = document.getElementById('tool-cursor');
2343
+
2344
+ canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
2345
+ canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
2346
+ canvas.addEventListener('mousemove', (e) => {
2347
+ const { x, y } = getPos(e);
2348
+ cursor.style.left = `${x}px`;
2349
+ cursor.style.top = `${y}px`;
2350
+ });
2351
 
2352
+ // Drawing Events
2353
+ const start = (e) => {
2354
+ isDrawing = true;
2355
+ ctx.beginPath();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2356
 
2357
+ // Re-apply settings (state might change)
2358
+ ctx.globalCompositeOperation = currentMode;
2359
+ ctx.strokeStyle = currentPenColor;
2360
+ ctx.lineWidth = currentLineWidth;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2361
 
2362
+ const { x, y } = getPos(e);
2363
+ ctx.moveTo(x, y);
2364
+ };
 
 
 
 
2365
 
2366
+ const move = (e) => {
2367
+ if (!isDrawing) return;
2368
+ const { x, y } = getPos(e);
2369
+ ctx.lineTo(x, y);
2370
+ ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2371
  };
2372
 
2373
+ const end = () => {
2374
+ isDrawing = false;
 
 
 
2375
  };
2376
 
2377
+ canvas.onmousedown = start;
2378
+ canvas.onmousemove = move;
2379
+ canvas.onmouseup = end;
2380
+ canvas.onmouseleave = end;
2381
 
2382
+ // Touch support
2383
+ canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
2384
+ canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
2385
+ canvas.ontouchend = (e) => { e.preventDefault(); end(); };
2386
+ };
2387
 
2388
+ function getPos(e) {
2389
+ const rect = canvas.getBoundingClientRect();
2390
+ return {
2391
+ x: e.clientX - rect.left,
2392
+ y: e.clientY - rect.top
2393
+ };
2394
+ }
2395
+
2396
+ // Unified Tool Handler
2397
+ window.setPenTool = (tool, color, btn) => {
2398
+ // UI Update
2399
+ document.querySelectorAll('.annotation-tool').forEach(b => {
2400
+ b.classList.remove('ring-white');
2401
+ b.classList.add('ring-transparent');
2402
+ });
2403
+ btn.classList.remove('ring-transparent');
2404
+ btn.classList.add('ring-white');
2405
+
2406
+ if (tool === 'eraser') {
2407
+ currentMode = 'destination-out';
2408
+ // Force larger eraser size (e.g., 3x current size or fixed large)
2409
+ // We'll multiply current selected size by 4 for better UX
2410
+ const multiplier = 4;
2411
+ // Store original explicitly if needed, but currentLineWidth is global.
2412
+ // We should dynamically adjust context lineWidth during draw, or just hack it here.
2413
+ // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
2414
+ // Better: Update cursor style only? No, actual draw needs it.
2415
+ // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
2416
+ // Simpler: Just change it. When user clicks size button, it resets.
2417
+ // But if user clicks Pen back? We need to restore.
2418
+ // Let's rely on setPenTool being called with color.
2419
+ // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
2420
+ // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
2421
+ // We need to change how draw() uses the width.
2422
+ // BUT, since we don't want to touch draw() deep inside:
2423
+ // We will hijack currentLineWidth.
2424
+ if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
2425
+ currentLineWidth = window.savedPenWidth * 4;
2426
+ } else {
2427
+ currentMode = 'source-over';
2428
+ currentPenColor = color;
2429
+ // Restore pen width
2430
+ if (window.savedPenWidth) {
2431
+ currentLineWidth = window.savedPenWidth;
2432
+ window.savedPenWidth = null;
2433
  }
2434
  }
2435
+ updateCursorStyle();
2436
+ };
2437
 
2438
+ // Size Handler
2439
+ window.setPenSize = (size, btn) => {
2440
+ currentLineWidth = size;
2441
+ updateSizeBtnUI();
2442
+ updateCursorStyle();
2443
+ };
2444
+
2445
+ function updateCursorStyle() {
2446
+ const cursor = document.getElementById('tool-cursor');
2447
+ if (!cursor) return;
2448
+
2449
+ // Size
2450
+ cursor.style.width = `${currentLineWidth}px`;
2451
+ cursor.style.height = `${currentLineWidth}px`;
2452
+
2453
+ // Color
2454
+ if (currentMode === 'destination-out') {
2455
+ // Eraser: White solid
2456
+ cursor.style.backgroundColor = 'white';
2457
+ cursor.style.borderColor = '#999';
2458
+ } else {
2459
+ // Pen: Tool color
2460
+ cursor.style.backgroundColor = currentPenColor;
2461
+ cursor.style.borderColor = 'rgba(255,255,255,0.8)';
2462
  }
2463
+ }
2464
 
2465
+ function updateSizeBtnUI() {
2466
+ document.querySelectorAll('.size-btn').forEach(b => {
2467
+ if (parseInt(b.dataset.size) === currentLineWidth) {
2468
+ b.classList.add('bg-gray-600', 'text-white');
2469
+ b.classList.remove('text-gray-400', 'hover:bg-gray-700');
2470
+ } else {
2471
+ b.classList.remove('bg-gray-600', 'text-white');
2472
+ b.classList.add('text-gray-400', 'hover:bg-gray-700');
2473
  }
2474
+ });
2475
+ }
2476
 
2477
+ window.clearCanvas = () => {
2478
+ if (canvas && ctx) {
2479
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2480
+ }
2481
+ };
 
2482
 
2483
+ /**
2484
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
2485
+ */
2486
+ function renderTransposedHeatmap(students) {
2487
+ const thead = document.getElementById('heatmap-header');
2488
+ const tbody = document.getElementById('heatmap-body');
2489
+
2490
+ if (students.length === 0) {
2491
+ thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
2492
+ tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
2493
+ return;
2494
+ }
2495
 
2496
+ // 1. Render Header (Students)
2497
+ // Sticky Top for Header Row
2498
+ // Sticky Left for the first cell ("Challenge/Student")
2499
+ let headerHtml = `
2500
  <th class="p-3 text-left sticky left-0 top-0 bg-gray-800 z-30 border-b border-gray-600 min-w-[200px] border-r border-gray-700 shadow-md">
2501
  <div class="flex justify-between items-end">
2502
  <span class="text-sm text-gray-400">題目</span>
 
2505
  </th>
2506
  `;
2507
 
2508
+ students.forEach(student => {
2509
+ headerHtml += `
2510
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
2511
  <div class="flex flex-col items-center space-y-2 py-2">
2512
  <div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-xs font-bold text-white uppercase border border-gray-500 shadow-sm relative">
 
2525
  </div>
2526
  </th>
2527
  `;
2528
+ });
2529
+ thead.innerHTML = headerHtml;
2530
 
2531
+ // 2. Render Body (Challenges as Rows)
2532
+ if (cachedChallenges.length === 0) {
2533
+ tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
2534
+ return;
2535
+ }
2536
 
2537
+ tbody.innerHTML = cachedChallenges.map((c, index) => {
2538
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
2539
+ const color = colors[c.level] || 'gray';
2540
+
2541
+ // Build Row Cells (One per student)
2542
+ const rowCells = students.map(student => {
2543
+ const p = student.progress?.[c.id];
2544
+ let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
2545
+ let content = '';
2546
+ let action = '';
2547
+
2548
+ if (p) {
2549
+ if (p.status === 'completed') {
2550
+ statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]';
2551
+ content = '✅';
2552
+ // Action restored: Allow direct click to open detailed view
2553
+ action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`;
2554
+ } else if (p.status === 'started') {
2555
+ // Check stuck
2556
+ const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
2557
+ const now = new Date();
2558
+ const diffMins = (now - startedAt) / 1000 / 60;
2559
+
2560
+ if (diffMins > 5) {
2561
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
2562
+ content = '🆘';
2563
+ } else {
2564
+ statusClass = 'bg-blue-600/20 border-blue-500';
2565
+ content = '🔵';
 
2566
  }
2567
  }
2568
+ }
2569
 
2570
+ return `
2571
  <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
2572
  <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
2573
  ${content}
2574
  </div>
2575
  </td>
2576
  `;
2577
+ }).join('');
2578
 
2579
+ // Row Header (Challenge Title)
2580
+ return `
2581
  <tr class="hover:bg-gray-800/50 transition-colors">
2582
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
2583
  <div class="flex items-center justify-between">
 
2597
  ${rowCells}
2598
  </tr>
2599
  `;
2600
+ }).join('');
2601
+ }
2602
 
2603
+ // Global scope for HTML access
2604
+ function showBroadcastModal(userId, challengeId) {
2605
+ const student = currentStudents.find(s => s.id === userId);
2606
+ if (!student) return;
2607
+
2608
+ const p = student.progress?.[challengeId];
2609
+ if (!p) return;
2610
+
2611
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
2612
+ const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2613
 
2614
+ const modal = document.getElementById('broadcast-modal');
2615
+ const content = document.getElementById('broadcast-content');
2616
+
2617
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0];
2618
+ document.getElementById('broadcast-author').textContent = student.nickname;
2619
+ document.getElementById('broadcast-challenge').textContent = title;
2620
+ // content is already just text, but let's be safe
2621
+ const rawText = p.prompt || p.code || '';
2622
+ const isCode = !p.prompt && !!p.code; // If only code exists, treat as code
2623
+ document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode);
2624
+ document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit
2625
+
2626
+ // Store IDs for actions
2627
+ modal.dataset.userId = userId;
2628
+ modal.classList.remove('hidden');
2629
+ // Animation trigger
2630
+ setTimeout(() => {
2631
+ content.classList.remove('scale-95', 'opacity-0');
2632
+ content.classList.add('opacity-100', 'scale-100');
2633
+ }, 10);
2634
  }
2635
 
2636
+ // Bind to window
2637
+ window.renderTransposedHeatmap = renderTransposedHeatmap;
2638
+ window.showBroadcastModal = showBroadcastModal;
2639
+ }
2640
+
2641