diff --git a/css/styles.css b/css/styles.css index d690c2e..1f7712d 100644 --- a/css/styles.css +++ b/css/styles.css @@ -786,3 +786,35 @@ p { margin: 0 0 1em; color: var(--text-muted); } .fruit-detail-header { padding: 1.25rem; } .filter-meta { margin-left: 0; width: 100%; } } + +/* ---------- Proper wiki images ---------- */ +.proper-image { + position: relative; + display: block; + width: 100%; + height: 100%; + background: #0b1220; +} + +.proper-image__fallback, +.proper-image__img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.proper-image__img { + object-fit: cover; + object-position: top center; + z-index: 1; +} + +.proper-image--fruit .proper-image__img { + object-fit: cover; + object-position: center; + padding: 0; + background: #0b1220; +} + +.proper-image.is-loaded .proper-image__fallback { opacity: 0; } diff --git a/index.html b/index.html index 6813642..9c1c837 100644 --- a/index.html +++ b/index.html @@ -42,7 +42,8 @@ - + + diff --git a/js/app.js b/js/app.js index 041ab15..ba52a8b 100644 --- a/js/app.js +++ b/js/app.js @@ -74,6 +74,16 @@ function statusBadgeClass(status) { return 'badge--unknown'; } +function characterPosterLabel(c) { + if (c.bounty !== null && c.bounty !== undefined) return 'WANTED'; + if (/marine/i.test(c.affiliation || '')) return 'MARINE'; + return (c.affiliation || c.role || 'UNKNOWN').split(/[/(]/)[0].trim().toUpperCase(); +} + +function characterBountyOrRank(c) { + return c.bounty !== null && c.bounty !== undefined ? formatBounty(c.bounty) : c.role || 'Rank unknown'; +} + function characterCardHTML(c) { const accent = accentFor(c); const deadRibbon = /deceased/i.test(c.status || '') ? 'Deceased' : ''; @@ -83,13 +93,13 @@ function characterCardHTML(c) {
${deadRibbon} - WANTED + ${escapeHtml(characterPosterLabel(c))}
${characterPortraitSVG(c)}

${escapeHtml(c.name)}

${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}

-

${c.bounty ? formatBounty(c.bounty) : 'Bounty Unknown'}

+

${escapeHtml(characterBountyOrRank(c))}

${escapeHtml(c.affiliation)} ${fruitTag} diff --git a/js/character.js b/js/character.js index c141418..fbc2134 100644 --- a/js/character.js +++ b/js/character.js @@ -42,7 +42,7 @@

${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}

${escapeHtml(character.status || 'Unknown')} - ${character.bounty ? formatBounty(character.bounty) : 'Bounty Unknown'} + ${escapeHtml(characterBountyOrRank(character))} ${fruit ? `${escapeHtml(fruit.category)} User` : ''}
@@ -82,4 +82,6 @@
`; + + hydrateProperImages(root); })(); diff --git a/js/fruit.js b/js/fruit.js index ea9b321..2de632c 100644 --- a/js/fruit.js +++ b/js/fruit.js @@ -79,4 +79,6 @@ `; + + hydrateProperImages(root); })(); diff --git a/js/fruits.js b/js/fruits.js index bbeba43..4dce6a3 100644 --- a/js/fruits.js +++ b/js/fruits.js @@ -33,6 +33,7 @@ renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.'); resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`; + hydrateProperImages(grid); } chipRow.addEventListener('click', (e) => { diff --git a/js/main.js b/js/main.js index 9d41a5a..6382460 100644 --- a/js/main.js +++ b/js/main.js @@ -1,12 +1,17 @@ -/* Home page: character grid with search + affiliation + devil-fruit filters. */ +/* Home page: character grid with search + affiliation + devil-fruit + deceased filters. */ (function () { const grid = document.getElementById('character-grid'); const searchInput = document.getElementById('search-input'); const affiliationSelect = document.getElementById('affiliation-select'); const fruitOnlyCheckbox = document.getElementById('fruit-only-checkbox'); + const deceasedOnlyCheckbox = document.getElementById('deceased-only-checkbox'); const resultCount = document.getElementById('result-count'); + // Browsers may restore checkbox state across reloads; start with the full roster visible. + fruitOnlyCheckbox.checked = false; + deceasedOnlyCheckbox.checked = false; + document.getElementById('stat-characters').textContent = CHARACTERS.length; document.getElementById('stat-fruits').textContent = FRUITS.length; @@ -22,9 +27,11 @@ const term = searchInput.value.trim().toLowerCase(); const affiliation = affiliationSelect.value; const fruitOnly = fruitOnlyCheckbox.checked; + const deceasedOnly = deceasedOnlyCheckbox.checked; const results = CHARACTERS.filter((c) => { if (fruitOnly && !c.devilFruit) return false; + if (deceasedOnly && !/deceased/i.test(c.status || '')) return false; if (affiliation && c.affiliation !== affiliation) return false; if (term) { const haystack = `${c.name} ${c.epithet || ''} ${c.affiliation} ${c.role}`.toLowerCase(); @@ -35,11 +42,13 @@ renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.'); resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`; + hydrateProperImages(grid); } searchInput.addEventListener('input', applyFilters); affiliationSelect.addEventListener('change', applyFilters); fruitOnlyCheckbox.addEventListener('change', applyFilters); + deceasedOnlyCheckbox.addEventListener('change', applyFilters); applyFilters(); })(); diff --git a/js/portraits.js b/js/portraits.js index e4ef12c..8b2586e 100644 --- a/js/portraits.js +++ b/js/portraits.js @@ -74,7 +74,26 @@ function iconKeyForCharacter(c) { return 'star'; } -function characterPortraitSVG(c) { +function characterImageTitle(c) { + return c.imageTitle || c.name; +} + +function fruitImageTitle(f) { + if (f.imageTitle) return f.imageTitle; + if (f.id === 'gomu-gomu-no-mi') return 'Hito Hito no Mi, Model: Nika'; + return f.name; +} + +function properImageHTML(kind, title, label, fallback) { + return ` + + ${fallback} + + + `; +} + +function characterPortraitFallbackSVG(c) { const accent = accentFor(c); const hue2 = (hashHue(c.id + 'x') + 45) % 360; const icon = ROLE_ICONS[iconKeyForCharacter(c)] || ROLE_ICONS.star; @@ -104,7 +123,7 @@ function characterPortraitSVG(c) { const CATEGORY_HEX = { Paramecia: '#a06cd5', Zoan: '#4c9a5a', Logia: '#3f9bd0' }; -function fruitPortraitSVG(f) { +function fruitPortraitFallbackSVG(f) { const color = CATEGORY_HEX[f.category] || '#e3b04b'; const glow = f.subtype && /mythical|special/i.test(f.subtype); const uid = f.id.replace(/[^a-z0-9]/g, ''); @@ -126,3 +145,84 @@ function fruitPortraitSVG(f) { `; } + + +function characterPortraitSVG(c) { + return properImageHTML('character', characterImageTitle(c), `${c.name} portrait`, characterPortraitFallbackSVG(c)); +} + +function fruitPortraitSVG(f) { + return properImageHTML('fruit', fruitImageTitle(f), `${f.name} fruit`, fruitPortraitFallbackSVG(f)); +} + +const IMAGE_CACHE_KEY = 'opwikiProperImages:v1'; +const IMAGE_API_ENDPOINT = 'https://onepiece.fandom.com/api.php'; +let properImageCache = null; + +function getProperImageCache() { + if (properImageCache) return properImageCache; + try { + properImageCache = JSON.parse(localStorage.getItem(IMAGE_CACHE_KEY) || '{}'); + } catch (_) { + properImageCache = {}; + } + return properImageCache; +} + +function saveProperImageCache() { + try { + localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(getProperImageCache())); + } catch (_) { + // Non-critical: images still load for the current page if storage is unavailable. + } +} + +function wikiThumbnailUrl(title, size = 700) { + const params = new URLSearchParams({ + action: 'query', + prop: 'pageimages', + format: 'json', + piprop: 'thumbnail', + pithumbsize: String(size), + redirects: '1', + origin: '*', + titles: title, + }); + return `${IMAGE_API_ENDPOINT}?${params.toString()}`; +} + +async function resolveProperImage(title) { + const cache = getProperImageCache(); + if (Object.prototype.hasOwnProperty.call(cache, title)) return cache[title]; + + const response = await fetch(wikiThumbnailUrl(title)); + if (!response.ok) throw new Error(`Image lookup failed for ${title}`); + const data = await response.json(); + const page = Object.values(data.query?.pages || {})[0]; + const source = page?.thumbnail?.source || ''; + cache[title] = source; + saveProperImageCache(); + return source; +} + +function hydrateProperImages(root = document) { + const holders = [...root.querySelectorAll('.proper-image[data-image-title]')]; + holders.forEach(async (holder) => { + const img = holder.querySelector('.proper-image__img'); + if (!img || holder.dataset.loaded) return; + holder.dataset.loaded = 'pending'; + + try { + const source = await resolveProperImage(holder.dataset.imageTitle); + if (!source) throw new Error('No thumbnail returned'); + img.addEventListener('load', () => { + holder.classList.add('is-loaded'); + img.hidden = false; + }, { once: true }); + img.src = source; + holder.dataset.loaded = 'true'; + } catch (_) { + holder.dataset.loaded = 'fallback'; + } + }); +}