commit dc75262734fe3bd87e4f285475ccd10f6dfb5a3c
parent 92da98c03c9de96e218c2a3d063f0ceb1f4fd7bc
Author: Jean Schurger <jean@schurger.org>
Date: Wed, 4 Nov 2026 17:21:11 -0500
Modale : filtres par catégorie + correctif du défilement
Puces Tous / Cartouches / Cassettes / Jeux / Applications / Éducatif (catégorie extraite
du dossier TOSEC, stockée dans titles.json), combinées au filtre texte. Le défilement de
la modale ne fait plus défiler la page : overscroll-behavior: contain + verrou
body.overflow à l'ouverture, rendu à la fermeture (Échap/croix/fond via l'événement close).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
2 files changed, 55 insertions(+), 13 deletions(-)
diff --git a/tools/get_cartridges.py b/tools/get_cartridges.py
@@ -50,6 +50,16 @@ def slug_of(title):
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
return s or "cart"
+# Catégorie depuis le dossier TOSEC (Games / Applications / Educational).
+def cat_of(root):
+ if "Games" in root:
+ return "jeu"
+ if "Educational" in root:
+ return "educatif"
+ if "Applications" in root:
+ return "application"
+ return ""
+
def main():
rar = os.path.join(tempfile.gettempdir(), "exl100-tosec.rar")
if not os.path.exists(rar) or os.path.getsize(rar) < 1_000_000:
@@ -78,7 +88,7 @@ def main():
out = f"{slug}.bin"
with open(os.path.join(OUT, out), "wb") as f:
f.write(data)
- titles[out] = {"t": title, "by": by, "y": year}
+ titles[out] = {"t": title, "by": by, "y": year, "cat": cat_of(root)}
print(f" {out:22s} {len(data):6d} o crc={zlib.crc32(data) & 0xffffffff:08x} {title} — {by} {year}",
file=sys.stderr)
with open(os.path.join(OUT, "titles.json"), "w", encoding="utf-8") as f:
@@ -119,7 +129,7 @@ def extract_tapes(tmp, limit=120):
continue
with open(os.path.join(TAPES_OUT, out), "wb") as f:
f.write(data)
- titles[out] = {"t": title, "by": by, "y": year}
+ titles[out] = {"t": title, "by": by, "y": year, "cat": cat_of(root)}
if len(titles) >= limit:
break
if len(titles) >= limit:
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -72,7 +72,14 @@
.modal-head .x:hover { color: #fff; }
.modal-src { margin: 0; padding: 9px 14px; border-top: 1px solid #2a2c33; background: #1b1d22;
color: var(--muted); font-size: .8rem; }
- .programs { max-height: 60vh; overflow-y: auto; background: #17181c; }
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; border-bottom: 1px solid #2a2c33; }
+ .chip { background: #2b2d35; color: var(--muted); border: 1px solid #40434e; border-radius: 999px;
+ padding: 4px 12px; font: inherit; font-size: .82rem; cursor: pointer; }
+ .chip:hover { background: #363946; color: #fff; }
+ .chip.active { background: var(--cyan); color: #06282a; border-color: var(--cyan); font-weight: 600; }
+ /* Le défilement reste dans la liste (pas de chaînage vers la page) */
+ .programs { max-height: 56vh; overflow-y: auto; overscroll-behavior: contain; background: #17181c; }
+ .modal { overscroll-behavior: contain; }
.pg-group { position: sticky; top: 0; background: #1b1d22; color: var(--cyan); font-size: .78rem;
letter-spacing: .04em; text-transform: uppercase; padding: 6px 12px; border-bottom: 1px solid #2a2c33; }
.pg { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; width: 100%;
@@ -141,6 +148,14 @@
<input id="filter" type="search" placeholder="Filtrer…" autocomplete="off">
<button id="modal-close" type="button" class="x" aria-label="Fermer">✕</button>
</div>
+ <div id="chips" class="chips">
+ <button class="chip active" type="button" data-f="all">Tous</button>
+ <button class="chip" type="button" data-f="cart">Cartouches</button>
+ <button class="chip" type="button" data-f="tape">Cassettes</button>
+ <button class="chip" type="button" data-f="jeu">Jeux</button>
+ <button class="chip" type="button" data-f="application">Applications</button>
+ <button class="chip" type="button" data-f="educatif">Éducatif</button>
+ </div>
<div id="programs" class="programs" role="listbox" aria-label="Programmes"></div>
<p class="modal-src">Contenu préservé par le pack <b>TOSEC</b> sur
<a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> ; chaque entrée
@@ -305,7 +320,8 @@
// (éditeur, année) lue dans titles.json. On relance l'EXL 100 avec le programme choisi (ou le logo).
const programsEl = $('programs');
const modal = $('modal');
- $('pick').onclick = () => { modal.showModal(); $('filter').focus(); };
+ $('pick').onclick = () => { document.body.style.overflow = 'hidden'; modal.showModal(); $('filter').focus(); };
+ modal.addEventListener('close', () => { document.body.style.overflow = ''; }); // Échap/croix/fond : rend le défilement
$('modal-close').onclick = () => modal.close();
modal.addEventListener('click', (e) => { if (e.target === modal) modal.close(); }); // clic sur le fond ferme
let bootToken = 0; // annule une frappe automatique en cours si on change de programme
@@ -369,10 +385,12 @@
if (m.y) p.push(m.y);
return p.length ? p.join(', ') : 'source inconnue';
}
- function addRow(value, title, source) {
+ function addRow(value, title, source, cat) {
const b = document.createElement('button');
b.className = 'pg';
b.dataset.value = value;
+ b.dataset.cat = cat || '';
+ b.dataset.type = value.startsWith('cart:') ? 'cart' : value.startsWith('tape:') ? 'tape' : '';
b.dataset.search = (title + ' ' + source).toLowerCase();
const t = document.createElement('span'); t.className = 'pg-t'; t.textContent = title;
const s = document.createElement('span'); s.className = 'pg-s'; s.textContent = source;
@@ -387,26 +405,40 @@
if (!entries.length) return;
const h = document.createElement('div'); h.className = 'pg-group'; h.textContent = label;
programsEl.appendChild(h);
- for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m));
+ for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m), m.cat);
}
function populatePrograms() {
programsEl.innerHTML = '';
- addRow('', 'Logo Exelvision', 'aucune cartouche');
+ addRow('', 'Logo Exelvision', 'aucune cartouche', '');
addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:');
addGroup('Cassettes BASIC', '/roms/tapes/titles.json', 'tape:');
}
- // Filtre texte : masque les lignes qui ne correspondent pas, et les en-têtes vides.
- $('filter').oninput = (e) => {
- const q = e.target.value.trim().toLowerCase();
- for (const el of programsEl.querySelectorAll('.pg'))
- el.classList.toggle('hide', q !== '' && !el.dataset.search.includes(q));
+
+ // Filtres combinés : texte (titre/source) + une puce (type ou catégorie), et masquage des en-têtes vides.
+ let chip = 'all';
+ function applyFilter() {
+ const q = $('filter').value.trim().toLowerCase();
+ for (const el of programsEl.querySelectorAll('.pg')) {
+ const okText = q === '' || el.dataset.search.includes(q);
+ const okChip = chip === 'all' ||
+ (chip === 'cart' || chip === 'tape' ? el.dataset.type === chip : el.dataset.cat === chip);
+ el.classList.toggle('hide', !(okText && okChip));
+ }
let group = null, any = false;
for (const el of programsEl.children) {
if (el.classList.contains('pg-group')) { if (group) group.classList.toggle('hide', !any); group = el; any = false; }
else if (!el.classList.contains('hide')) any = true;
}
if (group) group.classList.toggle('hide', !any);
- };
+ }
+ $('filter').oninput = applyFilter;
+ for (const c of $('chips').querySelectorAll('.chip')) {
+ c.onclick = () => {
+ for (const o of $('chips').querySelectorAll('.chip')) o.classList.toggle('active', o === c);
+ chip = c.dataset.f;
+ applyFilter();
+ };
+ }
const Module = window.Module = {
canvas: canvas,