diff --git a/webui/app.css b/webui/app.css index 4b7e549..2a72b0a 100644 --- a/webui/app.css +++ b/webui/app.css @@ -41,3 +41,5 @@ main{max-width:100rem;margin-inline:auto} @media(max-width:768px){.media-row{grid-template-columns:1fr}.media-hint,#scrape-system{grid-column:1}.media-row-kinds .media-label{padding-top:0}.kind-tiles{grid-template-columns:repeat(auto-fill,minmax(6.8rem,1fr))}.job-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.segmented{flex-wrap:wrap}.media-bar{flex-wrap:wrap}.media-bar .primary{width:100%}} .kinds-area .media-hint{grid-column:auto} .media-head p{margin-top:0}@media(max-width:768px){.segmented{display:flex;width:100%}.segmented label{flex:1}.segmented span{text-align:center;padding:.45rem .5rem}} +.flag{width:1.35rem;height:.9rem;border-radius:.15rem;box-shadow:0 0 0 .0555556rem rgba(0,0,0,.18);flex:none;vertical-align:middle}.flag.globe{width:1rem;height:1rem;border-radius:50%;box-shadow:none}.device{width:1.25rem;height:1.25rem;flex:none}.device.arrow{width:.9rem;height:.9rem;opacity:.7}.source-logo{width:1.15rem;height:1.15rem;flex:none;border-radius:.3rem}img.source-logo{object-fit:contain}.segmented span[data-icon]{display:inline-flex;align-items:center;gap:.4rem}.segmented span[data-icon] b{font-weight:500}.region-pick{display:flex;align-items:center;gap:.6rem;font-size:.88rem;flex-wrap:wrap}.chip[aria-checked=true] .source-logo{box-shadow:0 0 0 .111111rem rgba(255,255,255,.7)}.version-tags .version-tag{display:inline-flex;align-items:center;gap:.2rem}.version-tags .version-tag.flags{border:0;padding:0}.version-tags .version-tag.flags .flag{width:1.15rem;height:.78rem} +@media(max-width:768px){.region-pick{width:100%}.region-pick .segmented span{padding:.4rem .3rem;font-size:.78rem;gap:.25rem}.region-pick .flag{width:1.1rem;height:.75rem}} diff --git a/webui/app.js b/webui/app.js index 476f2a8..c959423 100644 --- a/webui/app.js +++ b/webui/app.js @@ -719,6 +719,47 @@ window.addEventListener('hashchange', navigate); setInterval(() => { if (!document.hidden) reconnect(); }, 10000); navigate(); drawTransfers(); reconnect(); checkRelease(); + +// The WebUI's drawn icons (inline SVG, no image requests): flags for regions, the +// console and the PC, and a badge for each media source. The sources' badges are +// original drawings, not their owners' logos. +const EU_STARS = Array.from({ length: 12 }, (_, i) => { const a = i * Math.PI / 6; return ``; }).join(''); +const US_STRIPES = Array.from({ length: 7 }, (_, i) => ``).join(''); +const US_STARS = Array.from({ length: 12 }, (_, i) => ``).join(''); +const ICONS = { + 'flag-us': ``, + 'flag-eu': ``, + 'flag-jp': '', + 'flag-kr': '', + globe: '', + ps5: '', + pc: '', + arrow: '', + 'source-libretro': '', + 'source-screenscraper': '', + 'source-launchbox': '', + 'source-emumovies': '' }; +function iconNode(name) { + const template = document.createElement('template'); + template.innerHTML = ICONS[name] || ''; + return template.content.firstElementChild; +} +// A region tag of a game's name, as a flag ("USA", "Europe", "USA, Europe"...). +const REGION_FLAGS = { USA: 'flag-us', Europe: 'flag-eu', Japan: 'flag-jp', Korea: 'flag-kr', World: 'globe' }; +function tagNode(tag) { + const node = element('span', undefined, 'version-tag'); + const flags = tag.split(/,\s*/).map(part => REGION_FLAGS[part]).filter(Boolean); + if (flags.length && flags.length === tag.split(/,\s*/).length) { for (const flag of flags) node.append(iconNode(flag)); node.title = tag; node.setAttribute('aria-label', tag); node.classList.add('flags'); } + else node.textContent = tag; + return node; +} +// The page's own [data-icon] spans get their drawing once. +for (const span of $$('[data-icon]')) { + const name = span.dataset.icon, label = span.textContent; + span.replaceChildren(); + span.append(iconNode(name === 'pc-to-ps5' ? 'pc' : name)); // the words say where it goes + span.append(element('b', label)); +} // The games and their media (src/scraper.h): the shared library every frontend reads. const KIND_NAMES = { cover: 'Cover', screenshot: 'Screenshot', title: 'Title', logo: 'Logo', video: 'Video' }; function gameName(game) { return game.name || game.label; } @@ -766,7 +807,7 @@ function drawGames() { const badges = element('div', undefined, 'media-badges'); for (const kind of game.media) badges.append(element('span', KIND_NAMES[kind] || kind)); const tags = element('div', undefined, 'version-tags'); - for (const tag of gameTags(game)) tags.append(element('span', tag)); + for (const tag of gameTags(game)) tags.append(tagNode(tag)); card.title = game.path.split('/').pop(); info.append(element('strong', gameName(game)), tags, element('small', system.name), badges); card.append(pick, cover, info); grid.append(card); @@ -819,7 +860,9 @@ function drawMethod() { function drawSources() { const box = $('#scrape-sources'); box.replaceChildren(); for (const source of scraperSettings.sources) { - const chip = element('button', source.name, 'chip'); chip.type = 'button'; + const chip = element('button', undefined, 'chip'); chip.type = 'button'; + const logo = iconNode('source-' + source.id); if (logo) chip.append(logo); + chip.append(element('span', source.name)); chip.setAttribute('role', 'radio'); chip.setAttribute('aria-checked', source.id === scrapeSource); if (!source.available) { chip.disabled = true; chip.append(element('em', 'Coming', 'soon-tag')); } chip.addEventListener('click', () => { scrapeSource = source.id; drawSources(); drawKinds(); }); @@ -877,7 +920,7 @@ async function openMedia() { for (const radio of $$('input[name="scrape-method"]')) radio.checked = radio.value === scraperSettings.mode; scrapeSource = scraperSettings.sources.some(s => s.id === scraperSettings.source && s.available) ? scraperSettings.source : 'libretro'; scrapeKinds = new Set(scraperSettings.kinds); - $('#scrape-region').value = scraperSettings.region; + for (const radio of $$('input[name="scrape-region"]')) radio.checked = radio.value === scraperSettings.region; const select = $('#scrape-system'), chosen = select.value || $('#games-system').value; select.replaceChildren(...library.systems.map(s => new Option(`${s.name} (${s.games.length})`, s.id))); if (chosen) select.value = chosen; @@ -897,7 +940,7 @@ $('#scrape-start').addEventListener('click', async () => { const lines = scope === 'selected' ? [...selectedGames].map(([path, system]) => `${system}\t${path}`) : library.systems.filter(s => scope === 'all' || s.id === $('#scrape-system').value).map(s => `${s.id}\t`); if (!kinds.length || !lines.length) { drawSummary(); return; } - const query = new URLSearchParams({ mode: method, source: scrapeSource, kinds: kinds.join(','), region: $('#scrape-region').value, language: scraperSettings.language || 'en', overwrite: $('#scrape-overwrite').checked ? '1' : '0' }); + const query = new URLSearchParams({ mode: method, source: scrapeSource, kinds: kinds.join(','), region: $('input[name="scrape-region"]:checked')?.value || 'us', language: scraperSettings.language || 'en', overwrite: $('#scrape-overwrite').checked ? '1' : '0' }); $('#scrape-start').disabled = true; $('#scrape-result').textContent = ''; try { await api('/api/scraper/settings?' + query, { method: 'POST' }); diff --git a/webui/index.html b/webui/index.html index aa6f2f5..b8f0c24 100644 --- a/webui/index.html +++ b/webui/index.html @@ -25,11 +25,11 @@