mirror of
https://github.com/Cametendo/devil-fruit-encyclopedia.git
synced 2026-09-14 00:09:47 +02:00
Compare commits
1 Commits
codex/add-
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34908f7108 |
@@ -786,3 +786,35 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
.fruit-detail-header { padding: 1.25rem; }
|
.fruit-detail-header { padding: 1.25rem; }
|
||||||
.filter-meta { margin-left: 0; width: 100%; }
|
.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; }
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
<select id="affiliation-select" class="select">
|
<select id="affiliation-select" class="select">
|
||||||
<option value="">All affiliations</option>
|
<option value="">All affiliations</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="checkbox"><input type="checkbox" id="fruit-only-checkbox" /> Devil Fruit users only</label>
|
<label class="checkbox"><input type="checkbox" id="fruit-only-checkbox" autocomplete="off" /> Devil Fruit users only</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" id="deceased-only-checkbox" autocomplete="off" /> Deceased only</label>
|
||||||
<span class="filter-meta" id="result-count"></span>
|
<span class="filter-meta" id="result-count"></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
14
js/app.js
14
js/app.js
@@ -74,6 +74,16 @@ function statusBadgeClass(status) {
|
|||||||
return 'badge--unknown';
|
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) {
|
function characterCardHTML(c) {
|
||||||
const accent = accentFor(c);
|
const accent = accentFor(c);
|
||||||
const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : '';
|
const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : '';
|
||||||
@@ -83,13 +93,13 @@ function characterCardHTML(c) {
|
|||||||
<a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}">
|
<a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}">
|
||||||
<div class="char-card__poster">
|
<div class="char-card__poster">
|
||||||
${deadRibbon}
|
${deadRibbon}
|
||||||
<span class="char-card__wanted">WANTED</span>
|
<span class="char-card__wanted">${escapeHtml(characterPosterLabel(c))}</span>
|
||||||
<div class="char-card__avatar">${characterPortraitSVG(c)}</div>
|
<div class="char-card__avatar">${characterPortraitSVG(c)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="char-card__body">
|
<div class="char-card__body">
|
||||||
<h3>${escapeHtml(c.name)}</h3>
|
<h3>${escapeHtml(c.name)}</h3>
|
||||||
<p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p>
|
<p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p>
|
||||||
<p class="char-card__bounty">${c.bounty ? formatBounty(c.bounty) : 'Bounty Unknown'}</p>
|
<p class="char-card__bounty">${escapeHtml(characterBountyOrRank(c))}</p>
|
||||||
<div class="char-card__tags">
|
<div class="char-card__tags">
|
||||||
<span class="tag">${escapeHtml(c.affiliation)}</span>
|
<span class="tag">${escapeHtml(c.affiliation)}</span>
|
||||||
${fruitTag}
|
${fruitTag}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
|
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
|
||||||
<div class="profile-badges">
|
<div class="profile-badges">
|
||||||
<span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span>
|
<span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span>
|
||||||
<span class="badge badge--bounty">${character.bounty ? formatBounty(character.bounty) : 'Bounty Unknown'}</span>
|
<span class="badge badge--bounty">${escapeHtml(characterBountyOrRank(character))}</span>
|
||||||
${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''}
|
${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,4 +82,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
hydrateProperImages(root);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -79,4 +79,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
hydrateProperImages(root);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
|
|
||||||
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
|
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
|
||||||
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
|
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
|
||||||
|
hydrateProperImages(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
chipRow.addEventListener('click', (e) => {
|
chipRow.addEventListener('click', (e) => {
|
||||||
|
|||||||
11
js/main.js
11
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 () {
|
(function () {
|
||||||
const grid = document.getElementById('character-grid');
|
const grid = document.getElementById('character-grid');
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const affiliationSelect = document.getElementById('affiliation-select');
|
const affiliationSelect = document.getElementById('affiliation-select');
|
||||||
const fruitOnlyCheckbox = document.getElementById('fruit-only-checkbox');
|
const fruitOnlyCheckbox = document.getElementById('fruit-only-checkbox');
|
||||||
|
const deceasedOnlyCheckbox = document.getElementById('deceased-only-checkbox');
|
||||||
const resultCount = document.getElementById('result-count');
|
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-characters').textContent = CHARACTERS.length;
|
||||||
document.getElementById('stat-fruits').textContent = FRUITS.length;
|
document.getElementById('stat-fruits').textContent = FRUITS.length;
|
||||||
|
|
||||||
@@ -22,9 +27,11 @@
|
|||||||
const term = searchInput.value.trim().toLowerCase();
|
const term = searchInput.value.trim().toLowerCase();
|
||||||
const affiliation = affiliationSelect.value;
|
const affiliation = affiliationSelect.value;
|
||||||
const fruitOnly = fruitOnlyCheckbox.checked;
|
const fruitOnly = fruitOnlyCheckbox.checked;
|
||||||
|
const deceasedOnly = deceasedOnlyCheckbox.checked;
|
||||||
|
|
||||||
const results = CHARACTERS.filter((c) => {
|
const results = CHARACTERS.filter((c) => {
|
||||||
if (fruitOnly && !c.devilFruit) return false;
|
if (fruitOnly && !c.devilFruit) return false;
|
||||||
|
if (deceasedOnly && !/deceased/i.test(c.status || '')) return false;
|
||||||
if (affiliation && c.affiliation !== affiliation) return false;
|
if (affiliation && c.affiliation !== affiliation) return false;
|
||||||
if (term) {
|
if (term) {
|
||||||
const haystack = `${c.name} ${c.epithet || ''} ${c.affiliation} ${c.role}`.toLowerCase();
|
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.');
|
renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.');
|
||||||
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
|
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
|
||||||
|
hydrateProperImages(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchInput.addEventListener('input', applyFilters);
|
searchInput.addEventListener('input', applyFilters);
|
||||||
affiliationSelect.addEventListener('change', applyFilters);
|
affiliationSelect.addEventListener('change', applyFilters);
|
||||||
fruitOnlyCheckbox.addEventListener('change', applyFilters);
|
fruitOnlyCheckbox.addEventListener('change', applyFilters);
|
||||||
|
deceasedOnlyCheckbox.addEventListener('change', applyFilters);
|
||||||
|
|
||||||
applyFilters();
|
applyFilters();
|
||||||
})();
|
})();
|
||||||
|
|||||||
104
js/portraits.js
104
js/portraits.js
@@ -74,7 +74,26 @@ function iconKeyForCharacter(c) {
|
|||||||
return 'star';
|
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 `
|
||||||
|
<span class="proper-image proper-image--${kind}" data-image-title="${escapeHtml(title)}" data-image-kind="${kind}">
|
||||||
|
<span class="proper-image__fallback">${fallback}</span>
|
||||||
|
<img class="proper-image__img" alt="${escapeHtml(label)}" loading="lazy" referrerpolicy="no-referrer" hidden>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterPortraitFallbackSVG(c) {
|
||||||
const accent = accentFor(c);
|
const accent = accentFor(c);
|
||||||
const hue2 = (hashHue(c.id + 'x') + 45) % 360;
|
const hue2 = (hashHue(c.id + 'x') + 45) % 360;
|
||||||
const icon = ROLE_ICONS[iconKeyForCharacter(c)] || ROLE_ICONS.star;
|
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' };
|
const CATEGORY_HEX = { Paramecia: '#a06cd5', Zoan: '#4c9a5a', Logia: '#3f9bd0' };
|
||||||
|
|
||||||
function fruitPortraitSVG(f) {
|
function fruitPortraitFallbackSVG(f) {
|
||||||
const color = CATEGORY_HEX[f.category] || '#e3b04b';
|
const color = CATEGORY_HEX[f.category] || '#e3b04b';
|
||||||
const glow = f.subtype && /mythical|special/i.test(f.subtype);
|
const glow = f.subtype && /mythical|special/i.test(f.subtype);
|
||||||
const uid = f.id.replace(/[^a-z0-9]/g, '');
|
const uid = f.id.replace(/[^a-z0-9]/g, '');
|
||||||
@@ -126,3 +145,84 @@ function fruitPortraitSVG(f) {
|
|||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user