Files
libretro/website/src/components/FilePicker.astro
T
Abdessamad Derraz c916503958 fix: hide filter boxes and picker until wired
A filter box read "Filter Options41 rows" in the page text and the
file picker an empty "Setup Choose a setup": both do nothing without
the script. They are written hidden, keep their room so nothing moves,
and site.ts or the picker's script shows them. The count describes the
input and is spaced from the label. One box markup for the build and
the generator, and the Markdown copy skips hidden chrome.
2026-11-10 14:12:00 +01:00

294 lines
12 KiB
Plaintext

---
// "Which files are needed": pick a setup and systems, get the file list and
// the install command. Client side only: reads public/data/picker/*.json,
// which convert.py derives from install/<platform>.json. The selection
// follows install.py narrow(): a file is kept when one of its systems is
// chosen, a file with no system when "other" is chosen.
// The list follows the current data. release-advertised.json says what the
// published ZIP of each setup holds, so the page can say when the two
// differ. generate_site.py writes it from release_record.load_advertised():
// the release record reduced to the setups the registry marks release_pack,
// the rule the setup pages and get/ pages follow. A setup the release still
// holds a pack for but the registry no longer marks gets no ZIP line here.
import release from '../generated/release-advertised.json';
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
const packs = Object.fromEntries(
Object.values(release.packs ?? {}).flatMap((pack) =>
pack.platforms.map((platform) => [platform, { files: pack.files, size: pack.extracted_size }]),
),
);
---
{/* hidden until the script fills the setup list: without it the box is an
empty "Choose a setup", which a text dump and a reader without the
script would read. It keeps its room meanwhile, so nothing moves. */}
<div class="rb-picker" hidden data-base={base} data-release={JSON.stringify({ tag: release.tag ?? '', packs })}>
<fieldset>
<label for="rb-pick-platform">Setup</label>
<select id="rb-pick-platform" name="platform">
<option value="">Choose a setup</option>
</select>
</fieldset>
<div class="rb-pick-systems" hidden>
<p class="rb-pick-systems-head">
<strong>Systems</strong>{' '}
<button type="button" class="rb-text-button" data-all>All</button>{' '}
<button type="button" class="rb-text-button" data-none>None</button>
</p>
<input type="search" class="rb-pick-filter" placeholder="Type a console" aria-label="Type a console" />
<div class="rb-pick-list" role="group" aria-label="Systems"></div>
</div>
<div class="rb-pick-result" role="status" aria-live="polite"></div>
<p class="rb-pick-release" hidden></p>
<div class="rb-pick-output" hidden>
<p><strong>Install command</strong> for Linux, macOS, Steam Deck and handhelds</p>
<figure class="rb-pick-frame">
<figcaption>Terminal</figcaption>
<pre class="rb-pick-cmd"><code></code></pre>
</figure>
<button type="button" data-copy>Copy the command</button>
<details>
<summary>Files and where they go</summary>
<table>
<thead><tr><th scope="col">Destination</th><th scope="col">Size</th></tr></thead>
<tbody></tbody>
</table>
</details>
</div>
</div>
<script>
type Data = {
platform: string;
name: string;
folder: string;
generated: string;
systems: { id: string; title: string; native: string[] }[];
other: boolean;
files: [string, number, number[]][];
};
type Release = { tag: string; packs: Record<string, { files: number; size: number }> };
const INSTALL = 'curl -fsSL https://abdess.github.io/retrobios/i | sh -s --';
const MAX_ROWS = 500;
// A word that holds a hyphen (--platform, nintendo-64dd) never breaks
// inside: the same rule as the command blocks (ec-unbroken-tokens.mjs).
// Only spans are added, the text copied is the command itself.
const UNBROKEN = /(?<!\S)(?=\S*-)\S{1,48}(?!\S)/g;
const showCommand = (target: HTMLElement, command: string) => {
const nodes: (Node | string)[] = [];
let last = 0;
for (const match of command.matchAll(UNBROKEN)) {
nodes.push(command.slice(last, match.index));
const word = document.createElement('span');
word.className = 'rb-nb';
word.textContent = match[0];
nodes.push(word);
last = match.index! + match[0].length;
}
nodes.push(command.slice(last));
target.replaceChildren(...nodes);
};
const size = (bytes: number) =>
bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB`
: bytes >= 1024 ** 2 ? `${(bytes / 1024 ** 2).toFixed(1)} MB`
: bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB`
: `${bytes} B`;
for (const root of document.querySelectorAll<HTMLElement>('.rb-picker')) {
const base = root.dataset.base ?? '/';
const select = root.querySelector('select')!;
const systemsBox = root.querySelector<HTMLElement>('.rb-pick-systems')!;
const list = root.querySelector<HTMLElement>('.rb-pick-list')!;
const filter = root.querySelector<HTMLInputElement>('.rb-pick-filter')!;
const result = root.querySelector<HTMLElement>('.rb-pick-result')!;
const output = root.querySelector<HTMLElement>('.rb-pick-output')!;
const cmd = root.querySelector<HTMLElement>('.rb-pick-cmd code')!;
const tbody = root.querySelector('tbody')!;
const releaseLine = root.querySelector<HTMLElement>('.rb-pick-release')!;
const release: Release = JSON.parse(root.dataset.release ?? '{"tag":"","packs":{}}');
const count = (n: number) => n.toLocaleString('en');
let data: Data | null = null;
const params = new URLSearchParams(location.search);
const index: { platform: string; name: string }[] = await fetch(`${base}data/picker/index.json`).then((r) => r.json());
for (const entry of index) select.add(new Option(entry.name, entry.platform));
root.hidden = false;
const chosen = () => [...list.querySelectorAll<HTMLInputElement>('input:checked')].map((box) => box.value);
const render = () => {
if (!data) return;
const picked = chosen();
const wanted = new Set(picked.map((id) => data!.systems.findIndex((s) => s.id === id)));
const other = picked.includes('other');
const files = data.files.filter(([, , systems]) =>
systems.length ? systems.some((s) => wanted.has(s)) : other);
const total = files.reduce((sum, [, bytes]) => sum + bytes, 0);
const all = picked.length === data.systems.length + (data.other ? 1 : 0);
result.textContent = picked.length
? `${count(files.length)} files, ${size(total)}, for ${count(picked.length)} of ${count(data.systems.length + (data.other ? 1 : 0))} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
: 'Choose at least one system.';
output.hidden = !picked.length;
showCommand(cmd, `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`);
tbody.replaceChildren(...files.slice(0, MAX_ROWS).map(([dest, bytes]) => {
const row = document.createElement('tr');
const where = document.createElement('td');
const code = document.createElement('code');
code.textContent = dest;
where.append(code);
const weight = document.createElement('td');
weight.textContent = size(bytes);
row.append(where, weight);
return row;
}));
if (files.length > MAX_ROWS) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 2;
cell.textContent = `and ${count(files.length - MAX_ROWS)} more.`;
row.append(cell);
tbody.append(row);
}
const url = new URL(location.href);
url.searchParams.set('platform', data.platform);
if (all) url.searchParams.delete('system');
else url.searchParams.set('system', picked.join(','));
history.replaceState(null, '', url);
};
const load = async (platform: string, preset: string[] | null) => {
list.replaceChildren();
output.hidden = true;
releaseLine.hidden = true;
systemsBox.hidden = !platform;
if (!platform) { result.textContent = ''; return; }
result.textContent = 'Loading the file list...';
data = await fetch(`${base}data/picker/${platform}.json`).then((r) => r.json());
// Sorted by the name a reader looks for, not by id.
const choices = data!.systems
.map((s) => ({
id: s.id,
title: s.title || s.id,
detail: [s.id, ...s.native].filter((n) => n !== s.title).join(', '),
}))
.sort((a, b) => a.title.localeCompare(b.title, 'en'));
if (data!.other) choices.push({ id: 'other', title: 'Other files', detail: 'engines and computers no system owns' });
for (const choice of choices) {
const label = document.createElement('label');
const box = document.createElement('input');
box.type = 'checkbox';
box.value = choice.id;
box.checked = preset ? preset.includes(choice.id) : false;
const detail = document.createElement('small');
detail.textContent = choice.detail;
label.append(box, ` ${choice.title} `, detail);
list.append(label);
}
const pack = release.packs[data!.platform];
const listed = data!.files.reduce((sum, [, bytes]) => sum + bytes, 0);
if (pack && (pack.files !== data!.files.length || pack.size !== listed)) {
releaseLine.textContent = `The ZIP of release ${release.tag} holds ${count(pack.files)} files, ${size(pack.size)}. It was built from the list of that day. The command installs the current list.`;
releaseLine.hidden = false;
}
applyFilter();
render();
};
select.addEventListener('change', async () => {
await load(select.value, null);
if (select.value) filter.focus();
});
list.addEventListener('change', render);
const applyFilter = () => {
const query = filter.value.trim().toLowerCase();
for (const label of list.querySelectorAll<HTMLElement>('label')) {
label.hidden = !!query && !label.textContent!.toLowerCase().includes(query);
}
};
filter.addEventListener('input', applyFilter);
root.querySelector('[data-all]')!.addEventListener('click', () => {
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = true));
render();
});
root.querySelector('[data-none]')!.addEventListener('click', () => {
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = false));
render();
});
root.querySelector('[data-copy]')!.addEventListener('click', () => navigator.clipboard?.writeText(cmd.textContent ?? ''));
const initial = params.get('platform');
if (initial && index.some((entry) => entry.platform === initial)) {
select.value = initial;
await load(initial, params.get('system')?.split(',') ?? null);
}
}
</script>
<!-- Global: the checkboxes are created by the script, outside Astro's scoped styles. -->
<style is:global>
.rb-picker[hidden] { display: block; visibility: hidden; }
.rb-picker .rb-pick-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.25rem 1rem;
max-height: 22rem;
overflow: auto;
padding: 0.5rem;
border: 1px solid var(--rb-border);
border-radius: 4px;
}
.rb-picker .rb-pick-list label[hidden] { display: none; }
.rb-picker .rb-pick-list small { color: var(--sl-color-gray-3); }
.rb-picker .rb-pick-list label { display: flex; gap: 0.5rem; align-items: center; min-height: 32px; margin: 0; font-size: 0.9rem; }
.sl-markdown-content .rb-picker .rb-pick-list input { min-height: auto; width: 1.1rem; height: 1.1rem; margin: 0; flex: none; }
.rb-picker .rb-pick-systems-head { display: flex; gap: 0.5rem; align-items: center; }
.rb-picker .rb-pick-systems-head > * { margin: 0; }
.rb-picker .rb-pick-filter { width: 100%; margin: 0.5rem 0; }
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: break-word; }
.rb-picker .rb-pick-cmd .rb-nb { white-space: nowrap; }
.rb-picker .rb-pick-result { margin-top: 1rem; font-weight: 600; }
.rb-picker fieldset {
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
border: 0;
padding: 0;
}
.rb-picker fieldset label { font-weight: 600; }
.rb-picker .rb-pick-release { margin-top: 0.5rem; color: var(--sl-color-gray-2); }
/* All and None act on the list: plain text, not two primary buttons. */
.sl-markdown-content .rb-picker .rb-pick-systems-head .rb-text-button {
min-height: 32px;
padding: 0 0.25rem;
background: none;
color: var(--rb-link);
font-weight: 400;
text-decoration: underline;
}
/* The command in the same titled frame as the page's other commands. */
.rb-picker .rb-pick-frame {
margin: 0.5rem 0;
border: 1px solid var(--sl-color-gray-5);
border-radius: 4px;
overflow: hidden;
}
.rb-picker .rb-pick-frame figcaption {
padding: 0.35rem 1rem;
border-bottom: 1px solid var(--sl-color-gray-5);
background: var(--sl-color-gray-6);
color: var(--sl-color-gray-2);
font-size: var(--sl-text-xs);
}
.rb-picker .rb-pick-frame pre {
margin: 0;
padding: 0.75rem 1rem;
border: 0;
border-radius: 0;
}
</style>