mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-04 16:48:48 -05:00
950 lines
46 KiB
HTML
950 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tiny Torch Arena</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
fontFamily: {
|
|
sans: ['Inter', 'sans-serif'],
|
|
},
|
|
colors: {
|
|
torch: {
|
|
50: '#fff7ed',
|
|
100: '#ffedd5',
|
|
200: '#fed7aa',
|
|
300: '#fdba74',
|
|
400: '#fb923c',
|
|
500: '#f97316',
|
|
600: '#ea580c',
|
|
700: '#c2410c',
|
|
800: '#9a3412',
|
|
900: '#7c2d12',
|
|
950: '#431407',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
body {
|
|
background-color: #f8fafc; /* Slate 50 */
|
|
color: #0f172a; /* Slate 900 */
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Custom Scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: #f1f5f9;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: #cbd5e1;
|
|
border-radius: 4px;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #94a3b8;
|
|
}
|
|
|
|
/* --- ARENA STYLES --- */
|
|
.card-base {
|
|
background-color: #ffffff;
|
|
border: 1px solid #e2e8f0;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
|
}
|
|
.card-base:hover, .card-base.active {
|
|
border-color: #f97316;
|
|
box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.1), 0 2px 4px -1px rgba(249, 115, 22, 0.06);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
/* --- PUNCH CARD STYLES --- */
|
|
.punch-dot {
|
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
.punch-row {
|
|
transition: background-color 0.2s;
|
|
}
|
|
.sticky-col {
|
|
position: sticky;
|
|
left: 0;
|
|
background: white;
|
|
z-index: 10;
|
|
box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);
|
|
}
|
|
th.sticky-col {
|
|
background: #f8fafc;
|
|
z-index: 20;
|
|
}
|
|
.fade-in-row {
|
|
animation: fadeInUp 0.5s ease-out forwards;
|
|
opacity: 0;
|
|
}
|
|
@keyframes fadeInUp {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
/* Nav Tabs */
|
|
.nav-tab {
|
|
position: relative;
|
|
color: #64748b;
|
|
transition: color 0.2s;
|
|
}
|
|
.nav-tab.active {
|
|
color: #ea580c;
|
|
font-weight: 600;
|
|
}
|
|
.nav-tab::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -10px;
|
|
left: 0;
|
|
right: 0;
|
|
height: 2px;
|
|
background: #ea580c;
|
|
transform: scaleX(0);
|
|
transition: transform 0.2s;
|
|
}
|
|
.nav-tab.active::after {
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
/* Selection Ring */
|
|
.selection-ring {
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Sidebar/Nav adjustments to coexist */
|
|
header {
|
|
margin-top: 40px; /* Space for the global nav if needed */
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen flex flex-col font-sans p-6 md:p-8 h-screen overflow-hidden pt-40 mt-10"> <!-- Adjusted pt-40 for uniform distance to header across devices -->
|
|
|
|
<!-- Header & Navigation -->
|
|
<header class="flex-none mb-6 flex flex-col md:flex-row md:items-center justify-between gap-4 max-w-[1800px] mx-auto w-full border-b border-slate-200 pb-4">
|
|
<div class="flex items-center gap-6">
|
|
<div class="flex items-center gap-3">
|
|
<h1 class="text-2xl font-bold tracking-tight text-slate-900">Tiny Torch <span class="text-torch-600">Arena</span></h1>
|
|
</div>
|
|
|
|
<!-- Tab Navigation -->
|
|
<nav class="flex items-center gap-6 ml-4">
|
|
<button onclick="switchView('arena')" id="tab-arena" class="nav-tab active text-sm pb-1">
|
|
System Modules
|
|
</button>
|
|
<button onclick="switchView('community')" id="tab-community" class="nav-tab text-sm pb-1">
|
|
Community Progress
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="flex gap-3 text-xs font-medium text-slate-600" id="headerStats">
|
|
<div class="flex items-center gap-2 px-3 py-1.5 bg-white rounded-full border border-slate-200 shadow-sm">
|
|
<div class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></div>
|
|
<span id="activeBuildersCount">Loading...</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 px-3 py-1.5 bg-white rounded-full border border-slate-200 shadow-sm">
|
|
<div class="w-1.5 h-1.5 rounded-full bg-torch-500"></div>
|
|
20 Modules
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content Container -->
|
|
<main class="flex-grow max-w-[1800px] mx-auto w-full relative h-full overflow-hidden">
|
|
|
|
<!-- === VIEW 1: SYSTEM ARENA (Grid) === -->
|
|
<div id="view-arena" class="h-full overflow-y-auto pb-20 pr-2">
|
|
|
|
<div class="mb-6">
|
|
<h2 class="text-xl font-semibold text-slate-800 mb-1">System Architecture</h2>
|
|
<p class="text-slate-500 text-sm max-w-2xl">
|
|
Track the reconstruction of the core PyTorch library modules.
|
|
</p>
|
|
<!-- User Progress Summary -->
|
|
<div id="userProgressSummary" class="mt-2 text-sm text-torch-600 font-medium hidden">
|
|
<!-- Injected by JS -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modules Grid -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 mb-12" id="modulesGrid">
|
|
<!-- Cards injected by JS -->
|
|
</div>
|
|
|
|
<!-- Active Module Details Section -->
|
|
<div id="arenaDetails" class="hidden opacity-0 transition-opacity duration-500 pb-10">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-bold text-slate-900 flex items-center gap-2">
|
|
<span id="activeModuleTitle" class="text-torch-600">torch.nn</span>
|
|
<span class="text-slate-400 font-light">|</span>
|
|
<span class="text-slate-600 font-normal text-lg">Contributors</span>
|
|
</h2>
|
|
<p class="text-slate-500 text-sm mt-0.5" id="activeModuleDesc">Neural network layers and utilities.</p>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<select class="bg-white border border-slate-200 rounded-lg px-3 py-1.5 text-sm text-slate-700 focus:outline-none focus:border-torch-500 cursor-pointer shadow-sm">
|
|
<option>Most Commits</option>
|
|
<option>Recent Activity</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider border-b border-slate-200">
|
|
<th class="p-4 font-semibold">Builder</th>
|
|
<th class="p-4 font-semibold text-center">Completed On</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="contributorsBody" class="text-sm divide-y divide-slate-100">
|
|
<!-- Rows injected by JS -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- === VIEW 2: COMMUNITY PUNCH CARD (Leaderboard) === -->
|
|
<div id="view-community" class="hidden h-full flex flex-col">
|
|
|
|
<!-- Controls -->
|
|
<div class="flex justify-between items-end mb-4 flex-none">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-slate-800 mb-1">Module Progress</h2>
|
|
<p class="text-slate-500 text-sm">
|
|
Live progress of students building Tiny Torch from scratch.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3 bg-white p-1 rounded-lg border border-slate-200 shadow-sm">
|
|
<span class="text-xs font-semibold text-slate-400 ml-2 uppercase tracking-wide">Sort By</span>
|
|
<button onclick="sortUsers('progress')" id="btn-progress" class="px-3 py-1.5 text-xs font-medium rounded-md bg-slate-100 text-torch-700">Progress</button>
|
|
<button onclick="sortUsers('name')" id="btn-name" class="px-3 py-1.5 text-xs font-medium rounded-md text-slate-600 hover:bg-slate-50">Name</button>
|
|
<button onclick="sortUsers('institution')" id="btn-institution" class="px-3 py-1.5 text-xs font-medium rounded-md text-slate-600 hover:bg-slate-50">Institution</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full Height Scrollable Table -->
|
|
<div class="bg-white border border-slate-200 rounded-xl shadow-sm flex-grow overflow-hidden flex flex-col relative">
|
|
<div class="overflow-auto flex-grow h-full custom-scrollbar" id="tableContainer">
|
|
<table class="w-full text-left border-collapse min-w-[1400px]">
|
|
<!-- Removed 'uppercase' class from thead -->
|
|
<thead class="bg-slate-50 text-slate-500 text-[10px] tracking-wider border-b border-slate-200 sticky top-0 z-30 shadow-sm">
|
|
<tr id="leaderboardHeaderRow">
|
|
<!-- Added 'align-bottom' and 'pb-4' to lower the text -->
|
|
<th class="p-3 font-semibold sticky-col min-w-[250px] z-40 bg-slate-50 border-r border-slate-200 align-bottom pb-4">
|
|
Community Member
|
|
</th>
|
|
<!-- JS will inject columns 01-20 here -->
|
|
<!-- Added 'align-bottom' and 'pb-4' here as well for consistency -->
|
|
<th class="p-3 font-semibold text-center w-24 bg-slate-50 border-l border-slate-200 align-bottom pb-4">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="leaderboardBody" class="text-sm divide-y divide-slate-100">
|
|
<!-- Rows injected by JS -->
|
|
</tbody>
|
|
</table>
|
|
<!-- Lazy Load Sentinel -->
|
|
<div id="sentinel" class="h-10 w-full flex justify-center items-center py-4">
|
|
<i class="ph-bold ph-spinner animate-spin text-torch-500"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<script type="module" src="app.js"></script>
|
|
|
|
<script>
|
|
const SUPABASE_URL = "https://zrvmjrxhokwwmjacyhpq.supabase.co/functions/v1";
|
|
const NETLIFY_URL = "https://tinytorch.netlify.app";
|
|
|
|
// --- DATASET: TINY TORCH CURRICULUM ---
|
|
const milestones = [
|
|
{
|
|
id: "01_tensor", short: "01", title: "Tensors", year: "1890", val: 0.05,
|
|
desc: "Foundation of N-dimensional arrays.",
|
|
icon: "ph-cube", contributors: 0
|
|
},
|
|
{
|
|
id: "02_activations", short: "02", title: "Activations", year: "1943", val: 0.10,
|
|
desc: "ReLU, Sigmoid, Softmax.",
|
|
icon: "ph-function", contributors: 0
|
|
},
|
|
{
|
|
id: "03_layers", short: "03", title: "Linear Layers", year: "1958", val: 0.15,
|
|
desc: "The Perceptron & Dense connections.",
|
|
icon: "ph-rows", contributors: 0
|
|
},
|
|
{
|
|
id: "04_losses", short: "04", title: "Loss Functions", year: "1805", val: 0.18,
|
|
desc: "MSE, CrossEntropy, Optimization targets.",
|
|
icon: "ph-trend-down", contributors: 0
|
|
},
|
|
{
|
|
id: "05_dataloader", short: "05", title: "Data Pipelines", year: "2012", val: 0.22,
|
|
desc: "Efficient batching and pre-fetching.",
|
|
icon: "ph-database", contributors: 0
|
|
},
|
|
{
|
|
id: "06_autograd", short: "06", title: "Autograd", year: "1970", val: 0.26,
|
|
desc: "Automatic Differentiation.",
|
|
icon: "ph-lightning", contributors: 0
|
|
},
|
|
{
|
|
id: "07_optimizers", short: "07", title: "Optimizers", year: "1951", val: 0.30,
|
|
desc: "SGD, Adam, Scheduling.",
|
|
icon: "ph-trend-up", contributors: 0
|
|
},
|
|
{
|
|
id: "08_training", short: "08", title: "Training Loops", year: "1986", val: 0.35,
|
|
desc: "The learning cycle logic.",
|
|
icon: "ph-arrows-clockwise", contributors: 0
|
|
},
|
|
{
|
|
id: "09_convolutions", short: "09", title: "Convolutions (CNN)", year: "1989", val: 0.40,
|
|
desc: "Conv2d, MaxPool, Vision.",
|
|
icon: "ph-squares-four", contributors: 0
|
|
},
|
|
{
|
|
id: "10_tokenization", short: "10", title: "Tokenization", year: "1994", val: 0.45,
|
|
desc: "BPE, WordPiece, Text processing.",
|
|
icon: "ph-text-t", contributors: 0
|
|
},
|
|
{
|
|
id: "11_embeddings", short: "11", title: "Embeddings", year: "2013", val: 0.50,
|
|
desc: "Vector representations of meaning.",
|
|
icon: "ph-graph", contributors: 0
|
|
},
|
|
{
|
|
id: "12_attention", short: "12", title: "Attention", year: "2014", val: 0.55,
|
|
desc: "Focusing on relevant context.",
|
|
icon: "ph-eye", contributors: 0
|
|
},
|
|
{
|
|
id: "13_transformers", short: "13", title: "Transformers", year: "2017", val: 0.60,
|
|
desc: "Self-attention blocks.",
|
|
icon: "ph-robot", contributors: 0
|
|
},
|
|
{
|
|
id: "14_profiling", short: "14", title: "Profiling", year: "2018", val: 0.65,
|
|
desc: "Bottleneck analysis.",
|
|
icon: "ph-gauge", contributors: 0
|
|
},
|
|
{
|
|
id: "15_quantization", short: "15", title: "Quantization", year: "2015", val: 0.70,
|
|
desc: "INT8, FP16, precision reduction.",
|
|
icon: "ph-scales", contributors: 0
|
|
},
|
|
{
|
|
id: "16_compression", short: "16", title: "Compression", year: "2015", val: 0.75,
|
|
desc: "Pruning & Distillation.",
|
|
icon: "ph-arrows-in", contributors: 0
|
|
},
|
|
{
|
|
id: "17_acceleration", short: "17", title: "Acceleration", year: "2016", val: 0.85,
|
|
desc: "KV-Cache for fast inference.",
|
|
icon: "ph-rocket", contributors: 0
|
|
},
|
|
{
|
|
id: "18_memoization", short: "18", title: "Memoization", year: "2022", val: 0.80,
|
|
desc: "Hardware optimization (TPU/GPU).",
|
|
icon: "ph-memory", contributors: 0
|
|
},
|
|
{
|
|
id: "19_benchmarking", short: "19", title: "Benchmarking", year: "2018", val: 0.90,
|
|
desc: "Performance measurement.",
|
|
icon: "ph-chart-bar", contributors: 0
|
|
},
|
|
{
|
|
id: "20_capstone", short: "20", title: "Capstone", year: "2024", val: 1.00,
|
|
desc: "Complete End-to-End System.",
|
|
icon: "ph-flag", contributors: 0
|
|
}
|
|
];
|
|
|
|
// --- GLOBAL VARIABLES (DOM Elements) ---
|
|
let modulesGrid, arenaDetails, contributorsBody, leaderboardHeaderRow, leaderboardBody, sentinel;
|
|
|
|
// --- GLOBAL STATE ---
|
|
let allUsers = [];
|
|
let currentPage = 0;
|
|
const pageSize = 50;
|
|
let activeCardId = null;
|
|
let userCompletedModules = new Set();
|
|
let moduleCompletersMap = new Map(); // moduleIndex (0-19) -> Array of users
|
|
|
|
// --- VIEW SWITCHING LOGIC ---
|
|
function switchView(viewName) {
|
|
const viewArena = document.getElementById('view-arena');
|
|
const viewCommunity = document.getElementById('view-community');
|
|
const tabArena = document.getElementById('tab-arena');
|
|
const tabCommunity = document.getElementById('tab-community');
|
|
|
|
if (viewName === 'arena') {
|
|
viewArena.classList.remove('hidden');
|
|
viewCommunity.classList.add('hidden');
|
|
tabArena.classList.add('active');
|
|
tabCommunity.classList.remove('active');
|
|
} else {
|
|
viewArena.classList.add('hidden');
|
|
viewCommunity.classList.remove('hidden');
|
|
tabArena.classList.remove('active');
|
|
tabCommunity.classList.add('active');
|
|
|
|
// Initialize infinite scroll if first time
|
|
if (leaderboardBody && leaderboardBody.children.length === 0) {
|
|
initLeaderboard();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- ARENA VIEW LOGIC ---
|
|
|
|
function renderSystemModules() {
|
|
if (!modulesGrid) return;
|
|
|
|
modulesGrid.innerHTML = milestones.map(mod => {
|
|
const modIdx = parseInt(mod.id.split('_')[0], 10);
|
|
// Check if user has completed this module (module indices in completion set are usually numbers)
|
|
const isCompleted = userCompletedModules.has(modIdx) || userCompletedModules.has(mod.id) || userCompletedModules.has(modIdx.toString());
|
|
const progressPct = isCompleted ? 100 : 0;
|
|
const statusLabel = isCompleted ? 'Completed' : 'Not Started';
|
|
const statusColor = isCompleted ? 'text-green-600' : 'text-slate-500';
|
|
|
|
return `
|
|
<div
|
|
onclick="selectSystemModule('${mod.id}')"
|
|
id="card-${mod.id}"
|
|
class="card-base rounded-xl p-5 cursor-pointer group relative overflow-hidden"
|
|
>
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-lg bg-slate-100 flex items-center justify-center group-hover:bg-torch-50 group-hover:text-torch-600 transition-colors text-slate-500">
|
|
<i class="ph-bold ${mod.icon} text-xl"></i>
|
|
</div>
|
|
<div>
|
|
<h3 class="font-bold text-slate-800 text-sm tracking-wide group-hover:text-torch-600 transition-colors">
|
|
<span class="text-xs text-slate-400 font-mono mr-1">${mod.short}</span>
|
|
${mod.title}
|
|
</h3>
|
|
<p class="text-xs text-slate-500 line-clamp-1">${mod.desc}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-between items-end mb-2">
|
|
<span class="text-xs font-medium ${statusColor}">${statusLabel}</span>
|
|
<span class="text-xs font-bold text-slate-800">${progressPct}%</span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-1.5 mb-4 overflow-hidden">
|
|
<div class="bg-gradient-to-r from-torch-500 to-torch-600 h-1.5 rounded-full" style="width: ${progressPct}%"></div>
|
|
</div>
|
|
<div class="flex justify-between items-center border-t border-slate-100 pt-3">
|
|
<div class="flex items-center gap-2">
|
|
<i class="ph-fill ph-users text-slate-400 text-xs"></i>
|
|
<span class="text-xs text-slate-500" id="count-${mod.id}">Loading...</span>
|
|
</div>
|
|
<span class="px-2 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 border border-slate-200">Active</span>
|
|
</div>
|
|
<div class="absolute inset-0 border-2 border-torch-500 rounded-xl opacity-0 scale-95 transition-all duration-200 pointer-events-none selection-ring"></div>
|
|
</div>
|
|
`}).join('');
|
|
}
|
|
|
|
window.selectSystemModule = function(id) {
|
|
if (activeCardId) {
|
|
const prevCard = document.getElementById(`card-${activeCardId}`);
|
|
if (prevCard) {
|
|
prevCard.classList.remove('active');
|
|
prevCard.querySelector('.selection-ring').classList.remove('opacity-100', 'scale-100');
|
|
prevCard.querySelector('.selection-ring').classList.add('opacity-0', 'scale-95');
|
|
}
|
|
}
|
|
activeCardId = id;
|
|
const card = document.getElementById(`card-${id}`);
|
|
if(card) {
|
|
card.classList.add('active');
|
|
card.querySelector('.selection-ring').classList.remove('opacity-0', 'scale-95');
|
|
card.querySelector('.selection-ring').classList.add('opacity-100', 'scale-100');
|
|
}
|
|
|
|
const modData = milestones.find(m => m.id === id);
|
|
document.getElementById('activeModuleTitle').textContent = modData.title;
|
|
document.getElementById('activeModuleDesc').textContent = modData.desc;
|
|
|
|
// Show real contributors if available
|
|
const modIdx = parseInt(id.split('_')[0], 10);
|
|
const contribs = moduleCompletersMap.get(modIdx) || [];
|
|
|
|
contributorsBody.innerHTML = contribs.length > 0 ? contribs.slice(0, 20).map((c, i) => {
|
|
const displayName = c.display_name || (c.username ? c.username.split('@')[0] : 'Anonymous');
|
|
const date = c.finished_at ? new Date(c.finished_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : 'N/A';
|
|
return `
|
|
<tr class="hover:bg-slate-50 cursor-pointer" onclick="window.openProfileModal('${c.id}')">
|
|
<td class="p-4 flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white overflow-hidden bg-slate-200">
|
|
${c.avatar_url ? `<img src="${c.avatar_url}" class="w-full h-full object-cover">` :
|
|
`<span style="color: #555;">${displayName.charAt(0).toUpperCase()}</span>`}
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="font-medium text-slate-800">${displayName}</span>
|
|
<span class="text-xs text-slate-400">${c.institution || ''}</span>
|
|
</div>
|
|
</td>
|
|
<td class="p-4 text-center text-slate-500 font-medium">${date}</td>
|
|
</tr>
|
|
`}).join('') : `<tr><td colspan="2" class="p-4 text-center text-slate-500">No builders have completed this module yet.</td></tr>`;
|
|
|
|
arenaDetails.classList.remove('hidden');
|
|
setTimeout(() => {
|
|
arenaDetails.classList.remove('opacity-0');
|
|
arenaDetails.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
}, 10);
|
|
};
|
|
|
|
// --- PUNCH CARD VIEW LOGIC ---
|
|
|
|
function initLeaderboard() {
|
|
if(!leaderboardHeaderRow) return;
|
|
|
|
const firstTh = leaderboardHeaderRow.firstElementChild;
|
|
const lastTh = leaderboardHeaderRow.lastElementChild;
|
|
leaderboardHeaderRow.innerHTML = '';
|
|
leaderboardHeaderRow.appendChild(firstTh);
|
|
|
|
milestones.forEach((m) => {
|
|
const th = document.createElement('th');
|
|
th.className = `p-2 font-medium text-center min-w-[40px] group cursor-default align-bottom h-48 hover:bg-slate-50 transition-colors`;
|
|
th.innerHTML = `
|
|
<div class="flex flex-col items-center justify-end h-full gap-3" title="${m.desc}">
|
|
<div class="writing-mode-vertical transform rotate-180 text-xs font-semibold text-slate-500 group-hover:text-torch-600 transition-colors whitespace-nowrap tracking-wide" style="writing-mode: vertical-rl;">
|
|
${m.title}
|
|
</div>
|
|
<div class="flex flex-col items-center gap-1">
|
|
<span class="text-[9px] text-slate-400 font-mono">${m.short}</span>
|
|
<div class="w-1 h-1 rounded-full bg-slate-300 group-hover:bg-torch-500 transition-colors"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
leaderboardHeaderRow.appendChild(th);
|
|
});
|
|
leaderboardHeaderRow.appendChild(lastTh);
|
|
|
|
// Observer
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
loadMoreUsers();
|
|
}
|
|
}, { root: document.getElementById('tableContainer'), rootMargin: "200px" });
|
|
|
|
if(sentinel) observer.observe(sentinel);
|
|
|
|
loadMoreUsers();
|
|
}
|
|
|
|
function loadMoreUsers() {
|
|
if(!leaderboardBody) return;
|
|
|
|
const start = currentPage * pageSize;
|
|
const end = start + pageSize;
|
|
const batch = allUsers.slice(start, end);
|
|
|
|
if (batch.length === 0) {
|
|
if(sentinel) sentinel.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
batch.forEach((user, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'punch-row fade-in-row group';
|
|
tr.style.animationDelay = `${(idx % 10) * 0.05}s`;
|
|
|
|
let rowHtml = `
|
|
<td class="p-3 sticky-col bg-white border-r border-slate-100 group-hover:bg-slate-50 transition-colors z-10 cursor-pointer" onclick="window.openProfileModal('${user.id}')">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shadow-sm flex-none overflow-hidden bg-slate-100">
|
|
${user.avatar_url ? `<img src="${user.avatar_url}" class="w-full h-full object-cover">` :
|
|
`<span style="color:#555;">${user.display_name.charAt(0)}</span>`}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-medium text-slate-800 text-sm truncate">${user.display_name}</div>
|
|
<div class="text-[10px] text-slate-400 truncate">${user.institution}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
for (let i = 1; i <= 20; i++) {
|
|
const isDone = user.completed.has(i);
|
|
const dotClass = isDone ? 'bg-slate-800 group-hover:bg-torch-500' : 'bg-slate-100 border border-slate-200';
|
|
const mTitle = milestones[i-1] ? milestones[i-1].title : '';
|
|
rowHtml += `
|
|
<td class="p-1 text-center border-b border-slate-50">
|
|
<div class="w-full h-full flex items-center justify-center py-2">
|
|
<div
|
|
class="w-3 h-3 rounded-full punch-dot ${dotClass}"
|
|
onmouseenter="randomizeShape(this)"
|
|
onmouseleave="resetShape(this)"
|
|
title="${isDone ? mTitle : ''}"
|
|
></div>
|
|
</div>
|
|
</td>
|
|
`;
|
|
}
|
|
|
|
rowHtml += `
|
|
<td class="p-3 text-center font-mono font-bold text-slate-400 group-hover:text-torch-600 transition-colors border-l border-slate-100 bg-slate-50/50">
|
|
${user.total}
|
|
</td>
|
|
`;
|
|
|
|
tr.innerHTML = rowHtml;
|
|
fragment.appendChild(tr);
|
|
});
|
|
|
|
leaderboardBody.appendChild(fragment);
|
|
currentPage++;
|
|
}
|
|
|
|
// --- UTILS ---
|
|
window.randomizeShape = function(el) {
|
|
const r1 = Math.floor(Math.random() * 60) + 20;
|
|
const r2 = Math.floor(Math.random() * 60) + 20;
|
|
const r3 = Math.floor(Math.random() * 60) + 20;
|
|
const r4 = Math.floor(Math.random() * 60) + 20;
|
|
el.style.borderRadius = `${r1}% ${100-r1}% ${r3}% ${100-r3}% / ${r2}% ${r4}% ${100-r4}% ${100-r2}%`;
|
|
el.style.transform = `scale(1.4)`;
|
|
};
|
|
window.resetShape = function(el) {
|
|
el.style.borderRadius = '9999px';
|
|
el.style.transform = 'scale(1)';
|
|
};
|
|
|
|
window.sortUsers = function(criteria) {
|
|
document.querySelectorAll('[id^="btn-"]').forEach(btn => {
|
|
btn.className = "px-3 py-1.5 text-xs font-medium rounded-md text-slate-600 hover:bg-slate-50 transition-colors";
|
|
});
|
|
const activeBtn = document.getElementById(`btn-${criteria}`);
|
|
activeBtn.className = "px-3 py-1.5 text-xs font-medium rounded-md bg-slate-100 text-torch-700 ring-1 ring-slate-200";
|
|
|
|
if (criteria === 'progress') allUsers.sort((a, b) => b.total - a.total);
|
|
if (criteria === 'name') allUsers.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
|
if (criteria === 'institution') {
|
|
allUsers.sort((a, b) => {
|
|
const getRank = (inst) => {
|
|
if (!inst || inst.trim() === '') return 2; // Last: Blank/Null
|
|
if (inst === 'Independent') return 1; // Middle: Independent
|
|
return 0; // First: Known Institutions
|
|
};
|
|
const rankA = getRank(a.institution);
|
|
const rankB = getRank(b.institution);
|
|
|
|
if (rankA !== rankB) return rankA - rankB;
|
|
return (a.institution || "").localeCompare(b.institution || "");
|
|
});
|
|
}
|
|
|
|
if(leaderboardBody) leaderboardBody.innerHTML = '';
|
|
currentPage = 0;
|
|
loadMoreUsers();
|
|
const container = document.getElementById('tableContainer');
|
|
if(container) container.scrollTop = 0;
|
|
};
|
|
|
|
// --- DATA FETCHING ---
|
|
async function fetchUserProgress() {
|
|
let token = localStorage.getItem("tinytorch_token");
|
|
if (!token) {
|
|
document.getElementById('userProgressSummary').textContent = "Sign in to track your progress.";
|
|
document.getElementById('userProgressSummary').classList.remove('hidden');
|
|
renderSystemModules();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Fetch user profile logic similar to dashboard.html
|
|
// Simplified for brevity - assumes token is valid or handles 401 poorly (user will just see 0 progress)
|
|
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.completed_modules) {
|
|
data.completed_modules.forEach(id => userCompletedModules.add(id));
|
|
|
|
document.getElementById('userProgressSummary').textContent =
|
|
`You have completed ${userCompletedModules.size} of 20 modules. Keep building!`;
|
|
document.getElementById('userProgressSummary').classList.remove('hidden');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Error fetching user progress", e);
|
|
}
|
|
renderSystemModules();
|
|
}
|
|
|
|
async function fetchAllCommunityProgress() {
|
|
const userMap = new Map(); // id -> userObj
|
|
|
|
// 1. Fetch ALL profiles (Master List)
|
|
try {
|
|
const res = await fetch(`${SUPABASE_URL}/search-profiles`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query: "", page: 0, limit: 1000 })
|
|
});
|
|
|
|
if (res.ok) {
|
|
const profiles = await res.json();
|
|
profiles.forEach(p => {
|
|
if (!p.id) return;
|
|
|
|
userMap.set(p.id, {
|
|
id: p.id,
|
|
username: p.display_name || p.full_name || 'Anonymous', // Fallback for display
|
|
display_name: p.display_name || p.full_name || 'Anonymous',
|
|
institution: Array.isArray(p.institution) ? p.institution.join(", ") : (p.institution || 'Independent'),
|
|
avatar_url: p.avatar_url || null,
|
|
completed: new Set(),
|
|
total: 0
|
|
});
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Error fetching all profiles:", e);
|
|
}
|
|
|
|
// 2. Fetch Module Completers (Progress)
|
|
const promises = [];
|
|
for (let i = 1; i <= 20; i++) {
|
|
promises.push(
|
|
fetch(`${SUPABASE_URL}/get-module-completers?module_id=${i}`)
|
|
.then(r => r.ok ? r.json() : [])
|
|
.then(users => ({ moduleId: i, users }))
|
|
.catch(() => ({ moduleId: i, users: [] }))
|
|
);
|
|
}
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
// 3. Merge Progress into User Map using ID
|
|
results.forEach(({ moduleId, users }) => {
|
|
users.forEach(u => {
|
|
const key = u.id;
|
|
if (!key) return;
|
|
|
|
if (!userMap.has(key)) {
|
|
// User has progress but wasn't in search-profiles? Add them.
|
|
// Completer data has 'avatar', profile has 'avatar_url'
|
|
userMap.set(key, {
|
|
id: key,
|
|
username: u.username || 'Anonymous',
|
|
display_name: u.username ? u.username.split('@')[0] : 'Anonymous',
|
|
institution: 'Independent', // Completers endpoint might not have institution
|
|
avatar_url: u.avatar || null,
|
|
completed: new Set(),
|
|
total: 0
|
|
});
|
|
}
|
|
const userObj = userMap.get(key);
|
|
userObj.completed.add(moduleId);
|
|
if (!userObj.completionDates) userObj.completionDates = {};
|
|
userObj.completionDates[moduleId] = u.finished_at;
|
|
});
|
|
});
|
|
|
|
// 4. Backfill progressive modules logic
|
|
for (const user of userMap.values()) {
|
|
if (user.completed.size > 0) {
|
|
const maxModule = Math.max(...user.completed);
|
|
for (let i = 1; i < maxModule; i++) {
|
|
user.completed.add(i);
|
|
}
|
|
}
|
|
user.total = user.completed.size;
|
|
}
|
|
|
|
// 5. Rebuild moduleCompletersMap and Update Counts
|
|
moduleCompletersMap.clear();
|
|
for(let i=1; i<=20; i++) moduleCompletersMap.set(i, []);
|
|
|
|
for (const user of userMap.values()) {
|
|
user.completed.forEach(mId => {
|
|
const list = moduleCompletersMap.get(mId);
|
|
if(list) {
|
|
list.push({
|
|
...user,
|
|
finished_at: user.completionDates ? user.completionDates[mId] : null
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update UI Counts
|
|
for(let i=1; i<=20; i++) {
|
|
const list = moduleCompletersMap.get(i);
|
|
const modIdStr = i.toString().padStart(2, '0');
|
|
const countEl = document.querySelector(`[id^="count-${modIdStr}"]`);
|
|
if (countEl) countEl.textContent = `${list.length} Builders`;
|
|
}
|
|
|
|
allUsers = Array.from(userMap.values()).sort((a,b) => b.total - a.total);
|
|
|
|
document.getElementById('activeBuildersCount').textContent = `${allUsers.length} Active Builders`;
|
|
|
|
// If on community view, refresh
|
|
if (!document.getElementById('view-community').classList.contains('hidden')) {
|
|
if(leaderboardBody) leaderboardBody.innerHTML = '';
|
|
currentPage = 0;
|
|
loadMoreUsers();
|
|
}
|
|
}
|
|
|
|
// --- INIT ---
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
modulesGrid = document.getElementById('modulesGrid');
|
|
arenaDetails = document.getElementById('arenaDetails');
|
|
contributorsBody = document.getElementById('contributorsBody');
|
|
leaderboardHeaderRow = document.getElementById('leaderboardHeaderRow');
|
|
leaderboardBody = document.getElementById('leaderboardBody');
|
|
sentinel = document.getElementById('sentinel');
|
|
|
|
renderSystemModules();
|
|
fetchUserProgress();
|
|
fetchAllCommunityProgress();
|
|
});
|
|
|
|
// --- PROFILE MODAL LOGIC ---
|
|
const pModal = document.createElement('div');
|
|
pModal.id = 'profile-modal';
|
|
pModal.style.cssText = 'display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.9); backdrop-filter:blur(5px); z-index:200; justify-content:center; align-items:center; opacity:0; transition:opacity 0.3s ease;';
|
|
pModal.innerHTML = `
|
|
<div style="background:#fff; padding:40px; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,0.1); max-width:500px; width:90%; position:relative; border:1px solid #eaeaea;">
|
|
<button id="profile-close" style="position:absolute; top:20px; right:20px; border:none; background:none; font-size:24px; cursor:pointer; color:#999;">×</button>
|
|
<div id="p-loading" style="display:block; text-align:center; color:#888;">Loading profile...</div>
|
|
<div id="p-content" style="display:none;">
|
|
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 20px;">
|
|
<img id="p-avatar" src="" alt="Avatar" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover; background: #eee; border: 2px solid #ff6600;">
|
|
<div>
|
|
<h2 id="p-name" style="margin:0; color:#222; font-size: 1.5rem;">User Name</h2>
|
|
<div id="p-meta" style="color:#666; font-family:'Courier New', monospace;">@username</div>
|
|
</div>
|
|
</div>
|
|
<p id="p-bio" style="color:#555; font-style:italic; margin-bottom:25px; border-left: 3px solid #ffefe0; padding-left: 15px; font-size: 0.95rem;">Bio goes here...</p>
|
|
<div style="background:#f9f9f9; padding:15px; border-radius:8px; font-size: 0.9rem;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; padding-bottom: 8px; border-bottom: 1px dashed #eee;">
|
|
<span style="color:#888;">Institution</span>
|
|
<span id="p-inst" style="font-weight:bold; color:#333; text-align:right;">-</span>
|
|
</div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; padding-bottom: 8px; border-bottom: 1px dashed #eee;">
|
|
<span style="color:#888;">Location</span>
|
|
<span id="p-loc" style="font-weight:bold; color:#333; text-align:right;">-</span>
|
|
</div>
|
|
<div id="p-links-row" style="display:none; flex-direction:column; gap:6px; margin-bottom:8px; padding-bottom: 8px; border-bottom: 1px dashed #eee;">
|
|
<span style="color:#888;">Links</span>
|
|
<div id="p-links" style="display:flex; flex-wrap:wrap; gap:8px;"></div>
|
|
</div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
<span style="color:#888;">Modules Completed</span>
|
|
<span id="p-modules-count" style="font-weight:bold; color:#333;">0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(pModal);
|
|
|
|
const pClose = document.getElementById('profile-close');
|
|
const pContent = document.getElementById('p-content');
|
|
const pLoading = document.getElementById('p-loading');
|
|
const elName = document.getElementById('p-name');
|
|
const elMeta = document.getElementById('p-meta');
|
|
const elBio = document.getElementById('p-bio');
|
|
const elInst = document.getElementById('p-inst');
|
|
const elLoc = document.getElementById('p-loc');
|
|
const elCount = document.getElementById('p-modules-count');
|
|
const elAvatar = document.getElementById('p-avatar');
|
|
const elLinksRow = document.getElementById('p-links-row');
|
|
const elLinks = document.getElementById('p-links');
|
|
|
|
function closeProfileModal() {
|
|
pModal.style.opacity = '0';
|
|
setTimeout(() => { pModal.style.display = 'none'; }, 300);
|
|
}
|
|
|
|
if(pClose) pClose.addEventListener('click', closeProfileModal);
|
|
pModal.addEventListener('click', (e) => { if (e.target === pModal) closeProfileModal(); });
|
|
|
|
window.openProfileModal = async function(userId) {
|
|
pModal.style.display = 'flex';
|
|
void pModal.offsetWidth;
|
|
pModal.style.opacity = '1';
|
|
pLoading.style.display = 'block';
|
|
pContent.style.display = 'none';
|
|
|
|
try {
|
|
const res = await fetch(`${SUPABASE_URL}/get-profile-details?id=${userId}`);
|
|
if(!res.ok) throw new Error("Failed to fetch");
|
|
const data = await res.json();
|
|
const p = data.profile || {};
|
|
const modules = data.completed_modules || [];
|
|
|
|
if(elAvatar) elAvatar.src = p.avatar || p.avatar_url || 'assets/flame.svg';
|
|
elName.textContent = p.display_name || p.full_name || (p.username ? p.username.split('@')[0].trim() : "Unknown User");
|
|
elMeta.textContent = `@${p.username ? p.username.split('@')[0].trim() : 'N/A'}`;
|
|
elBio.textContent = p.bio || p.summary || "No bio available.";
|
|
elInst.textContent = Array.isArray(p.institution) ? p.institution.join(", ") : (p.institution || "N/A");
|
|
elLoc.textContent = p.location || "N/A";
|
|
elCount.textContent = modules.length;
|
|
|
|
if(elLinksRow && elLinks) {
|
|
elLinks.innerHTML = '';
|
|
const sites = p.websites || p.website || [];
|
|
const sitesArray = Array.isArray(sites) ? sites : (sites ? [sites] : []);
|
|
if(sitesArray.length > 0) {
|
|
elLinksRow.style.display = 'flex';
|
|
sitesArray.forEach(site => {
|
|
if(!site) return;
|
|
const a = document.createElement('a');
|
|
a.href = site.startsWith('http') ? site : `https://${site}`;
|
|
a.target = '_blank';
|
|
a.textContent = site.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
a.style.cssText = 'color:#ff6600; text-decoration:none; font-size:0.8rem; border:1px solid #ffefe0; padding:2px 6px; border-radius:4px;';
|
|
elLinks.appendChild(a);
|
|
});
|
|
} else { elLinksRow.style.display = 'none'; }
|
|
}
|
|
pLoading.style.display = 'none';
|
|
pContent.style.display = 'block';
|
|
} catch (e) {
|
|
console.error(e);
|
|
pLoading.textContent = "Failed to load profile.";
|
|
}
|
|
};
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|