|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="UTF-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | +<title>Browse Challenge Projects</title> |
| 7 | +<style> |
| 8 | +:root { |
| 9 | + --primary: #0076a8; |
| 10 | + --primary-dark: #005a82; |
| 11 | + --accent: #e16b2f; |
| 12 | + --bg: #f5f7fa; |
| 13 | + --card-bg: #fff; |
| 14 | + --sidebar-bg: #fff; |
| 15 | + --border: #dde1e6; |
| 16 | + --text: #1a1a2e; |
| 17 | + --text-muted: #5a6270; |
| 18 | + --tag-bg: #e8f0fe; |
| 19 | + --tag-text: #1a56db; |
| 20 | + --beginner: #16a34a; |
| 21 | + --intermediate: #d97706; |
| 22 | + --advanced: #dc2626; |
| 23 | + --radius: 8px; |
| 24 | + --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06); |
| 25 | + --shadow-lg: 0 4px 12px rgba(0,0,0,.1); |
| 26 | +} |
| 27 | +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| 28 | +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } |
| 29 | + |
| 30 | +/* Header */ |
| 31 | +.header { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: #fff; padding: 28px 32px 20px; } |
| 32 | +.header h1 { font-size: 1.65rem; font-weight: 700; } |
| 33 | +.header p { opacity: .85; font-size: .95rem; margin-top: 4px; } |
| 34 | + |
| 35 | +/* Instructions */ |
| 36 | +.instructions { max-width: 1400px; margin: 0 auto; padding: 16px 32px 0; } |
| 37 | +.instructions details { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; } |
| 38 | +.instructions summary { cursor: pointer; font-weight: 600; font-size: .9rem; color: var(--primary); user-select: none; } |
| 39 | +.instructions .body { margin-top: 8px; font-size: .85rem; color: var(--text-muted); display: grid; grid-template-columns: 1fr 1fr; gap: 4px 32px; } |
| 40 | +.instructions .body p { margin: 2px 0; } |
| 41 | +.instructions .legend { margin-top: 8px; font-size: .82rem; color: var(--text-muted); border-top: 1px solid var(--border); padding-top: 8px; } |
| 42 | + |
| 43 | +/* Toolbar */ |
| 44 | +.toolbar { max-width: 1400px; margin: 0 auto; padding: 14px 32px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } |
| 45 | +.search-box { flex: 1; min-width: 260px; position: relative; } |
| 46 | +.search-box input { width: 100%; padding: 9px 14px 9px 36px; border: 1px solid var(--border); border-radius: var(--radius); font-size: .9rem; outline: none; transition: border .2s; } |
| 47 | +.search-box input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,118,168,.12); } |
| 48 | +.search-box svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-muted); } |
| 49 | +.result-count { font-size: .88rem; color: var(--text-muted); white-space: nowrap; } |
| 50 | +.btn-clear { padding: 8px 18px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius); font-size: .85rem; font-weight: 600; cursor: pointer; white-space: nowrap; transition: background .2s; } |
| 51 | +.btn-clear:hover { background: #c55a24; } |
| 52 | + |
| 53 | +/* Layout */ |
| 54 | +.main { max-width: 1400px; margin: 0 auto; padding: 0 32px 40px; display: flex; gap: 24px; align-items: flex-start; } |
| 55 | + |
| 56 | +/* Sidebar */ |
| 57 | +.sidebar { width: 270px; min-width: 270px; position: sticky; top: 12px; max-height: calc(100vh - 24px); overflow-y: auto; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px 0; box-shadow: var(--shadow); } |
| 58 | +.sidebar::-webkit-scrollbar { width: 5px; } |
| 59 | +.sidebar::-webkit-scrollbar-thumb { background: #c4c9d0; border-radius: 4px; } |
| 60 | +.filter-section { border-bottom: 1px solid var(--border); } |
| 61 | +.filter-section:last-child { border-bottom: none; } |
| 62 | +.filter-header { padding: 10px 14px; font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: var(--primary); cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none; } |
| 63 | +.filter-header::after { content: "▾"; font-size: .7rem; transition: transform .2s; } |
| 64 | +.filter-section.collapsed .filter-header::after { transform: rotate(-90deg); } |
| 65 | +.filter-options { padding: 0 14px 8px; } |
| 66 | +.filter-section.collapsed .filter-options { display: none; } |
| 67 | +.filter-options label { display: flex; align-items: flex-start; gap: 6px; padding: 3px 0; font-size: .83rem; color: var(--text); cursor: pointer; line-height: 1.35; } |
| 68 | +.filter-options input[type="checkbox"] { margin-top: 2px; accent-color: var(--primary); flex-shrink: 0; } |
| 69 | +.filter-options .count { color: var(--text-muted); font-size: .78rem; margin-left: auto; white-space: nowrap; } |
| 70 | + |
| 71 | +/* Cards grid */ |
| 72 | +.cards { flex: 1; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; } |
| 73 | +.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; display: flex; flex-direction: column; transition: box-shadow .2s, transform .15s; } |
| 74 | +.card:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); } |
| 75 | +.card-thumb { width: 100%; height: 180px; object-fit: cover; background: #e2e6ea; display: block; } |
| 76 | +.card-body { padding: 14px 16px 16px; flex: 1; display: flex; flex-direction: column; } |
| 77 | +.card-title { font-size: 1rem; font-weight: 700; color: var(--primary); text-decoration: none; line-height: 1.3; display: block; margin-bottom: 4px; } |
| 78 | +.card-title:hover { text-decoration: underline; } |
| 79 | +.card-id { font-size: .76rem; color: var(--text-muted); margin-bottom: 8px; } |
| 80 | +.card-row { margin-bottom: 6px; } |
| 81 | +.card-label { font-size: .74rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted); letter-spacing: .3px; margin-bottom: 2px; } |
| 82 | +.tags { display: flex; flex-wrap: wrap; gap: 4px; } |
| 83 | +.tag { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; line-height: 1.5; white-space: nowrap; } |
| 84 | +.tag-trend { background: #e0f2fe; color: #0369a1; } |
| 85 | +.tag-platform { background: #fef3c7; color: #92400e; } |
| 86 | +.tag-product { background: #ede9fe; color: #6d28d9; } |
| 87 | +.tag-area { background: #d1fae5; color: #065f46; } |
| 88 | +.tag-type { background: #fce7f3; color: #9d174d; } |
| 89 | +.tag-hw { background: #fee2e2; color: #991b1b; } |
| 90 | +.tag-partner { background: #fff7ed; color: #9a3412; } |
| 91 | +.difficulty-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: .76rem; font-weight: 700; color: #fff; } |
| 92 | +.difficulty-badge.beginner { background: var(--beginner); } |
| 93 | +.difficulty-badge.intermediate { background: var(--intermediate); } |
| 94 | +.difficulty-badge.advanced { background: var(--advanced); } |
| 95 | +.card-meta { margin-top: auto; padding-top: 8px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 6px; } |
| 96 | +.hw-badge { font-size: .76rem; color: var(--text-muted); } |
| 97 | + |
| 98 | +/* No results */ |
| 99 | +.no-results { grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: var(--text-muted); } |
| 100 | +.no-results h3 { font-size: 1.1rem; margin-bottom: 6px; } |
| 101 | + |
| 102 | +/* Loading / Error */ |
| 103 | +.status-msg { text-align: center; padding: 80px 20px; color: var(--text-muted); font-size: 1rem; } |
| 104 | + |
| 105 | +/* Responsive */ |
| 106 | +@media (max-width: 900px) { |
| 107 | + .main { flex-direction: column; } |
| 108 | + .sidebar { width: 100%; min-width: 0; position: static; max-height: none; } |
| 109 | + .cards { grid-template-columns: 1fr; } |
| 110 | + .header { padding: 20px 16px 14px; } |
| 111 | + .toolbar, .main, .instructions { padding-left: 16px; padding-right: 16px; } |
| 112 | + .instructions .body { grid-template-columns: 1fr; } |
| 113 | +} |
| 114 | +</style> |
| 115 | +</head> |
| 116 | +<body> |
| 117 | + |
| 118 | +<div class="header"> |
| 119 | + <h1>Browse Challenge Projects</h1> |
| 120 | + <p>Filter and explore MATLAB & Simulink project ideas</p> |
| 121 | +</div> |
| 122 | + |
| 123 | +<div class="instructions"> |
| 124 | + <details> |
| 125 | + <summary>How to use filters</summary> |
| 126 | + <div class="body"> |
| 127 | + <p>☑ Use filters on the left to refine results</p> |
| 128 | + <p>☑ You can select multiple values within a category</p> |
| 129 | + <p>☑ Filters combine across categories</p> |
| 130 | + <p>☑ Use the search box for keyword matching</p> |
| 131 | + <p>☑ Click <strong>Clear Filters</strong> to reset everything</p> |
| 132 | + </div> |
| 133 | + <div class="legend"> |
| 134 | + <strong>Logic:</strong> Multiple selections within a filter = <strong>OR</strong> | Different filters combined = <strong>AND</strong> |
| 135 | + </div> |
| 136 | + </details> |
| 137 | +</div> |
| 138 | + |
| 139 | +<div class="toolbar"> |
| 140 | + <div class="search-box"> |
| 141 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> |
| 142 | + <input type="text" id="searchInput" placeholder="Search by title, technology, toolbox, application, partner..."> |
| 143 | + </div> |
| 144 | + <span class="result-count" id="resultCount"></span> |
| 145 | + <button class="btn-clear" id="clearBtn">Clear Filters</button> |
| 146 | +</div> |
| 147 | + |
| 148 | +<div class="main"> |
| 149 | + <aside class="sidebar" id="sidebar"></aside> |
| 150 | + <section class="cards" id="cardsContainer"> |
| 151 | + <div class="status-msg" id="statusMsg">Loading projects…</div> |
| 152 | + </section> |
| 153 | +</div> |
| 154 | + |
| 155 | +<script> |
| 156 | +(function () { |
| 157 | + "use strict"; |
| 158 | + |
| 159 | + const FILTER_DEFS = [ |
| 160 | + { key: "technology_trends", label: "Technology Trends", tagClass: "tag-trend" }, |
| 161 | + { key: "mathworks_platforms", label: "MathWorks Platforms", tagClass: "tag-platform" }, |
| 162 | + { key: "mathworks_products", label: "MathWorks Products", tagClass: "tag-product" }, |
| 163 | + { key: "application_areas", label: "Application Areas", tagClass: "tag-area" }, |
| 164 | + { key: "difficulty", label: "Difficulty", tagClass: "", isScalar: true }, |
| 165 | + { key: "project_type", label: "Project Type", tagClass: "tag-type" }, |
| 166 | + { key: "hardware_required", label: "Hardware Required", tagClass: "", isScalar: true }, |
| 167 | + { key: "hardware_tags", label: "Hardware Tags", tagClass: "tag-hw" }, |
| 168 | + { key: "partners", label: "Partners", tagClass: "tag-partner" }, |
| 169 | + ]; |
| 170 | + |
| 171 | + let allProjects = []; |
| 172 | + let filterState = {}; // key -> Set of selected values |
| 173 | + let partnerOnly = false; |
| 174 | + |
| 175 | + const $sidebar = document.getElementById("sidebar"); |
| 176 | + const $cards = document.getElementById("cardsContainer"); |
| 177 | + const $search = document.getElementById("searchInput"); |
| 178 | + const $count = document.getElementById("resultCount"); |
| 179 | + const $clear = document.getElementById("clearBtn"); |
| 180 | + const $status = document.getElementById("statusMsg"); |
| 181 | + |
| 182 | + // ── Data loading ────────────────────────────────────────────── |
| 183 | + fetch("./projects/projects.json") |
| 184 | + .then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }) |
| 185 | + .then(data => { allProjects = data; boot(); }) |
| 186 | + .catch(err => { $status.textContent = "Failed to load projects.json — " + err.message; }); |
| 187 | + |
| 188 | + function boot() { |
| 189 | + $status.remove(); |
| 190 | + buildSidebar(); |
| 191 | + applyFilters(); |
| 192 | + $search.addEventListener("input", applyFilters); |
| 193 | + $clear.addEventListener("click", clearAll); |
| 194 | + } |
| 195 | + |
| 196 | + // ── Sidebar construction ────────────────────────────────────── |
| 197 | + function buildSidebar() { |
| 198 | + let html = ""; |
| 199 | + |
| 200 | + // Partner-only toggle |
| 201 | + html += `<div class="filter-section"> |
| 202 | + <div class="filter-header" style="cursor:default">Partner Filter</div> |
| 203 | + <div class="filter-options"> |
| 204 | + <label><input type="checkbox" id="partnerToggle"> Only partnered projects</label> |
| 205 | + </div> |
| 206 | + </div>`; |
| 207 | + |
| 208 | + FILTER_DEFS.forEach(def => { |
| 209 | + const vals = collectValues(def.key, def.isScalar); |
| 210 | + if (!vals.length) return; |
| 211 | + filterState[def.key] = new Set(); |
| 212 | + |
| 213 | + html += `<div class="filter-section" data-key="${def.key}">`; |
| 214 | + html += `<div class="filter-header">${def.label}</div>`; |
| 215 | + html += `<div class="filter-options">`; |
| 216 | + vals.forEach(v => { |
| 217 | + const id = `f_${def.key}_${v.replace(/\W/g, "_")}`; |
| 218 | + html += `<label><input type="checkbox" data-key="${def.key}" value="${escAttr(v)}" id="${id}"> ${esc(v)} <span class="count" data-ckey="${def.key}" data-cval="${escAttr(v)}"></span></label>`; |
| 219 | + }); |
| 220 | + html += `</div></div>`; |
| 221 | + }); |
| 222 | + |
| 223 | + $sidebar.innerHTML = html; |
| 224 | + |
| 225 | + // collapse / expand |
| 226 | + $sidebar.querySelectorAll(".filter-header").forEach(h => { |
| 227 | + h.addEventListener("click", () => h.parentElement.classList.toggle("collapsed")); |
| 228 | + }); |
| 229 | + |
| 230 | + // checkbox changes |
| 231 | + $sidebar.querySelectorAll('input[type="checkbox"][data-key]').forEach(cb => { |
| 232 | + cb.addEventListener("change", () => { |
| 233 | + const s = filterState[cb.dataset.key]; |
| 234 | + cb.checked ? s.add(cb.value) : s.delete(cb.value); |
| 235 | + applyFilters(); |
| 236 | + }); |
| 237 | + }); |
| 238 | + |
| 239 | + document.getElementById("partnerToggle").addEventListener("change", e => { |
| 240 | + partnerOnly = e.target.checked; |
| 241 | + applyFilters(); |
| 242 | + }); |
| 243 | + } |
| 244 | + |
| 245 | + function collectValues(key, isScalar) { |
| 246 | + const set = new Set(); |
| 247 | + allProjects.forEach(p => { |
| 248 | + if (isScalar) { if (p[key]) set.add(p[key]); } |
| 249 | + else { (p[key] || []).forEach(v => set.add(v)); } |
| 250 | + }); |
| 251 | + return [...set].sort((a, b) => a.localeCompare(b)); |
| 252 | + } |
| 253 | + |
| 254 | + // ── Filtering logic ─────────────────────────────────────────── |
| 255 | + function applyFilters() { |
| 256 | + const q = $search.value.trim().toLowerCase(); |
| 257 | + const filtered = allProjects.filter(p => { |
| 258 | + // search |
| 259 | + if (q && !matchesSearch(p, q)) return false; |
| 260 | + // partner toggle |
| 261 | + if (partnerOnly && !p.has_partner) return false; |
| 262 | + // checkbox filters |
| 263 | + for (const def of FILTER_DEFS) { |
| 264 | + const sel = filterState[def.key]; |
| 265 | + if (!sel || sel.size === 0) continue; |
| 266 | + if (def.isScalar) { |
| 267 | + if (!sel.has(p[def.key])) return false; |
| 268 | + } else { |
| 269 | + const arr = p[def.key] || []; |
| 270 | + if (!arr.some(v => sel.has(v))) return false; |
| 271 | + } |
| 272 | + } |
| 273 | + return true; |
| 274 | + }); |
| 275 | + |
| 276 | + renderCards(filtered); |
| 277 | + updateCounts(filtered); |
| 278 | + $count.textContent = `Showing ${filtered.length} of ${allProjects.length} projects`; |
| 279 | + } |
| 280 | + |
| 281 | + function matchesSearch(p, q) { |
| 282 | + const fields = [ |
| 283 | + p.title, p.id, |
| 284 | + ...(p.technology_trends || []), |
| 285 | + ...(p.mathworks_platforms || []), |
| 286 | + ...(p.mathworks_products || []), |
| 287 | + ...(p.application_areas || []), |
| 288 | + ...(p.project_type || []), |
| 289 | + ...(p.hardware_tags || []), |
| 290 | + ...(p.partners || []), |
| 291 | + ]; |
| 292 | + return fields.some(f => f && f.toLowerCase().includes(q)); |
| 293 | + } |
| 294 | + |
| 295 | + function updateCounts(filtered) { |
| 296 | + $sidebar.querySelectorAll(".count").forEach(span => { |
| 297 | + const key = span.dataset.ckey; |
| 298 | + const val = span.dataset.cval; |
| 299 | + const def = FILTER_DEFS.find(d => d.key === key); |
| 300 | + let n = 0; |
| 301 | + filtered.forEach(p => { |
| 302 | + if (def.isScalar) { if (p[key] === val) n++; } |
| 303 | + else { if ((p[key] || []).includes(val)) n++; } |
| 304 | + }); |
| 305 | + span.textContent = n; |
| 306 | + }); |
| 307 | + } |
| 308 | + |
| 309 | + // ── Card rendering ──────────────────────────────────────────── |
| 310 | + function renderCards(projects) { |
| 311 | + if (!projects.length) { |
| 312 | + $cards.innerHTML = `<div class="no-results"><h3>No projects match your filters</h3><p>Try broadening your search or clearing filters.</p></div>`; |
| 313 | + return; |
| 314 | + } |
| 315 | + $cards.innerHTML = projects.map(cardHTML).join(""); |
| 316 | + } |
| 317 | + |
| 318 | + function cardHTML(p) { |
| 319 | + const thumb = p.thumbnail |
| 320 | + ? `<img class="card-thumb" src="${escAttr(p.thumbnail)}" alt="" loading="lazy" onerror="this.style.display='none'">` |
| 321 | + : `<div class="card-thumb" style="display:flex;align-items:center;justify-content:center;color:#aaa;font-size:.9rem;">No image</div>`; |
| 322 | + |
| 323 | + const diffClass = p.difficulty.toLowerCase(); |
| 324 | + |
| 325 | + let partnerHTML = ""; |
| 326 | + if (p.has_partner && p.partners.length) { |
| 327 | + partnerHTML = `<div class="card-row"><div class="card-label">Partner</div><div class="tags">${p.partners.map(v => `<span class="tag tag-partner">${esc(v)}</span>`).join("")}</div></div>`; |
| 328 | + } |
| 329 | + |
| 330 | + return `<article class="card"> |
| 331 | + ${thumb} |
| 332 | + <div class="card-body"> |
| 333 | + <a class="card-title" href="${escAttr(p.project_url)}" target="_blank" rel="noopener">${esc(p.title)}</a> |
| 334 | + <div class="card-id">Project #${esc(p.id)}</div> |
| 335 | +
|
| 336 | + <div class="card-row"><div class="card-label">Technology Trends</div> |
| 337 | + <div class="tags">${p.technology_trends.map(v => `<span class="tag tag-trend">${esc(v)}</span>`).join("")}</div></div> |
| 338 | +
|
| 339 | + <div class="card-row"><div class="card-label">Platforms</div> |
| 340 | + <div class="tags">${p.mathworks_platforms.map(v => `<span class="tag tag-platform">${esc(v)}</span>`).join("")}</div></div> |
| 341 | +
|
| 342 | + ${p.mathworks_products.length ? `<div class="card-row"><div class="card-label">Products</div> |
| 343 | + <div class="tags">${p.mathworks_products.map(v => `<span class="tag tag-product">${esc(v)}</span>`).join("")}</div></div>` : ""} |
| 344 | +
|
| 345 | + <div class="card-row"><div class="card-label">Application Areas</div> |
| 346 | + <div class="tags">${p.application_areas.map(v => `<span class="tag tag-area">${esc(v)}</span>`).join("")}</div></div> |
| 347 | +
|
| 348 | + <div class="card-row"><div class="card-label">Project Type</div> |
| 349 | + <div class="tags">${p.project_type.map(v => `<span class="tag tag-type">${esc(v)}</span>`).join("")}</div></div> |
| 350 | +
|
| 351 | + ${p.hardware_tags.length ? `<div class="card-row"><div class="card-label">Hardware</div> |
| 352 | + <div class="tags">${p.hardware_tags.map(v => `<span class="tag tag-hw">${esc(v)}</span>`).join("")}</div></div>` : ""} |
| 353 | +
|
| 354 | + ${partnerHTML} |
| 355 | +
|
| 356 | + <div class="card-meta"> |
| 357 | + <span class="difficulty-badge ${diffClass}">${esc(p.difficulty)}</span> |
| 358 | + <span class="hw-badge">HW: ${esc(p.hardware_required)}</span> |
| 359 | + </div> |
| 360 | + </div> |
| 361 | + </article>`; |
| 362 | + } |
| 363 | + |
| 364 | + // ── Clear all ───────────────────────────────────────────────── |
| 365 | + function clearAll() { |
| 366 | + $search.value = ""; |
| 367 | + partnerOnly = false; |
| 368 | + const pt = document.getElementById("partnerToggle"); |
| 369 | + if (pt) pt.checked = false; |
| 370 | + Object.values(filterState).forEach(s => s.clear()); |
| 371 | + $sidebar.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false); |
| 372 | + applyFilters(); |
| 373 | + } |
| 374 | + |
| 375 | + // ── Helpers ─────────────────────────────────────────────────── |
| 376 | + function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } |
| 377 | + function escAttr(s) { return s.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">"); } |
| 378 | + |
| 379 | +})(); |
| 380 | +</script> |
| 381 | +</body> |
| 382 | +</html> |
0 commit comments