Skip to content

Commit a9ee562

Browse files
Add interactive project filter page (table.html)
Amp-Thread-ID: https://ampcode.com/threads/T-019d211c-730f-74d8-a8ee-a303cb7aad37 Co-authored-by: Amp <amp@ampcode.com>
1 parent bed1287 commit a9ee562

1 file changed

Lines changed: 382 additions & 0 deletions

File tree

table.html

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
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 &amp; 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> &nbsp;|&nbsp; 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,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
378+
379+
})();
380+
</script>
381+
</body>
382+
</html>

0 commit comments

Comments
 (0)