From 29a2243690472392acafe8b3959b6ec7e0ffc973 Mon Sep 17 00:00:00 2001 From: Cametendo Date: Sat, 15 Aug 2026 01:58:40 +0200 Subject: [PATCH] Add proper wiki images for portraits --- css/styles.css | 32 +++++++++++++++ js/character.js | 2 + js/fruit.js | 2 + js/fruits.js | 1 + js/main.js | 1 + js/portraits.js | 104 +++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 140 insertions(+), 2 deletions(-) diff --git a/css/styles.css b/css/styles.css index d690c2e..75f0e45 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: contain; + object-position: center; + padding: 8%; + background: radial-gradient(circle at 50% 45%, rgba(255,255,255,0.08), #0b1220 70%); +} + +.proper-image.is-loaded .proper-image__fallback { opacity: 0; } diff --git a/js/character.js b/js/character.js index c141418..2b0ca23 100644 --- a/js/character.js +++ b/js/character.js @@ -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..0f8637e 100644 --- a/js/main.js +++ b/js/main.js @@ -35,6 +35,7 @@ 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); 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'; + } + }); +}