Mudanças entre as edições de "Widget:Conquistas"
Ir para navegação
Ir para pesquisar
m |
m |
||
| Linha 6: | Linha 6: | ||
{{#widget:Item}} | {{#widget:Item}} | ||
{{#widget:Conquistas}} | {{#widget:Conquistas}} | ||
{{#invoke:Conquistas| | {{#invoke:Conquistas|renderTab|tab=geral}} | ||
Widget:Item é quem traz a CSS dos ícones (.reward-wrapper, | Widget:Item é quem traz a CSS dos ícones (.reward-wrapper, | ||
| Linha 73: | Linha 73: | ||
<!-- | <!-- | ||
Painéis vazios. O Módulo:Conquistas (#invoke | Painéis vazios. O Módulo:Conquistas (#invoke renderTab) emite os | ||
cards FORA do widget (Smarty não reparsa wikitext, então não dá pra | cards FORA do widget (Smarty não reparsa wikitext, então não dá pra | ||
passar como parâmetro). O JS abaixo move cada card pro painel certo | passar como parâmetro). O JS abaixo move cada card pro painel certo | ||
| Linha 80: | Linha 80: | ||
Uso na página da wiki: | Uso na página da wiki: | ||
{{#widget:Conquistas}} | {{#widget:Conquistas}} | ||
{{#invoke:Conquistas| | {{#invoke:Conquistas|renderTab|tab=geral}} | ||
--> | --> | ||
<div class="gla-conquistas-panel is-active" data-tab-content="geral"> | <div class="gla-conquistas-panel is-active" data-tab-content="geral"> | ||
| Linha 351: | Linha 351: | ||
flex-direction: column; | flex-direction: column; | ||
gap: 10px; | gap: 10px; | ||
position: relative; | |||
min-height: 0; | |||
} | |||
.gla-list.gla-tab-loading { | |||
min-height: 88px; | |||
opacity: 0.55; | |||
pointer-events: none; | |||
} | |||
.gla-list.gla-tab-loading::after { | |||
content: "Carregando..."; | |||
position: absolute; | |||
top: 50%; | |||
left: 50%; | |||
transform: translate(-50%, -50%); | |||
font-size: 13px; | |||
font-weight: 600; | |||
color: var(--gla-ink-2); | |||
letter-spacing: 0.04em; | |||
} | |||
.gla-list.gla-tab-error::after { | |||
content: "Não foi possível carregar esta aba. Recarregue a página."; | |||
position: absolute; | |||
top: 50%; | |||
left: 50%; | |||
transform: translate(-50%, -50%); | |||
width: min(320px, 90%); | |||
text-align: center; | |||
font-size: 13px; | |||
color: #b45309; | |||
line-height: 1.4; | |||
} | } | ||
| Linha 1 251: | Linha 1 284: | ||
buildTabCaches(); | buildTabCaches(); | ||
var REVEAL_STORAGE_KEY = "glaConquistasRevealed"; | |||
var | |||
var | function loadRevealed() { | ||
try { | |||
var raw = window.localStorage.getItem(REVEAL_STORAGE_KEY); | |||
if (!raw) return {}; | |||
var arr = JSON.parse(raw); | |||
if (!Array.isArray(arr)) return {}; | |||
var set = {}; | |||
for (var i = 0; i < arr.length; i++) { | |||
if (arr[i] != null) set[String(arr[i])] = true; | |||
} | |||
return set; | |||
} catch (e) { | |||
return {}; | |||
} | |||
} | } | ||
var revealedSet = loadRevealed(); | |||
if ( | // Abas já com cards no HTML (ex.: renderAll legado) ficam marcadas. | ||
// Com renderTab|tab=geral só "geral" vem na primeira carga. | |||
var loadedTabs = {}; | |||
var loadingTabs = {}; | |||
Object.keys(validTabs).forEach(function (tabName) { | |||
var list = validTabs[tabName]; | |||
if (!list) return; | |||
for (var i = 0; i < list.children.length; i++) { | |||
if (list.children[i].classList && list.children[i].classList.contains("gla-item")) { | |||
loadedTabs[tabName] = true; | |||
return; | |||
} | |||
} | |||
}); | |||
function getApiUrl() { | |||
if (window.mw && mw.util && mw.util.wikiScript) { | |||
return mw.util.wikiScript("api"); | |||
} | |||
var path = window.location.pathname || ""; | |||
if (path.indexOf("/index.php") !== -1) { | |||
return path.split("/index.php")[0] + "/api.php"; | |||
} | } | ||
return "/api.php"; | |||
} | |||
} | function fetchTabHtml(tabName) { | ||
var wikitext = "{{#invoke:Conquistas|renderTab|tab=" + tabName + "}}"; | |||
} | if (window.mw && mw.Api) { | ||
return new mw.Api().post({ | |||
action: "parse", | |||
text: wikitext, | |||
contentmodel: "wikitext", | |||
disablelimitreport: true | |||
}).then(function (data) { | |||
return data.parse.text["*"]; | |||
}); | |||
} | } | ||
var body = new URLSearchParams(); | |||
body.set("action", "parse"); | |||
body.set("format", "json"); | |||
body.set("text", wikitext); | |||
body.set("contentmodel", "wikitext"); | |||
body.set("disablelimitreport", "1"); | |||
return fetch(getApiUrl(), { | |||
method: "POST", | |||
credentials: "same-origin", | |||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, | |||
body: body.toString() | |||
}) | |||
.then(function (res) { return res.json(); }) | |||
.then(function (data) { | |||
if (data.error) throw new Error(data.error.info || "API parse failed"); | |||
return data.parse.text["*"]; | |||
}); | |||
} | |||
if ( | function applyRevealedToCards(cards) { | ||
if ( | if (!cards || !cards.length) return; | ||
for (var i = 0; i < cards.length; i++) { | |||
var card = cards[i]; | |||
if (card.getAttribute("data-hidden") !== "true") continue; | |||
var id = card.getAttribute("data-id"); | |||
if (id && revealedSet[id]) card.classList.add("is-revealed"); | |||
} | } | ||
} | } | ||
function mountCardsFromHtml(html, tabName) { | |||
var list = validTabs[tabName]; | |||
if (!list || !html) return; | |||
var tmp = document.createElement("div"); | |||
. | tmp.innerHTML = html; | ||
. | var moved = []; | ||
tmp.querySelectorAll(".gla-item").forEach(function (card) { | |||
list.appendChild(card); | |||
moved.push(card); | |||
}); | |||
applyRevealedToCards(moved); | |||
if (window.glaConquistasProcessRewards) { | |||
window.glaConquistasProcessRewards(list); | |||
} | |||
buildTabCaches(); | |||
} | } | ||
function | function ensureTabLoaded(tabName) { | ||
if (loadedTabs[tabName]) return Promise.resolve(); | |||
if ( | if (loadingTabs[tabName]) return loadingTabs[tabName]; | ||
var list = validTabs[tabName]; | |||
if (list) { | |||
list.classList.remove("gla-tab-error"); | |||
list.classList.add("gla-tab-loading"); | |||
} | |||
loadingTabs[tabName] = fetchTabHtml(tabName) | |||
.then(function (html) { | |||
mountCardsFromHtml(html, tabName); | |||
loadedTabs[tabName] = true; | |||
if (list) list.classList.remove("gla-tab-loading"); | |||
delete loadingTabs[tabName]; | |||
}) | |||
.catch(function () { | |||
if (list) { | |||
list.classList.remove("gla-tab-loading"); | |||
list.classList.add("gla-tab-error"); | |||
} | } | ||
delete loadingTabs[tabName]; | |||
}); | |||
return loadingTabs[tabName]; | |||
} | } | ||
// | // Estado global | ||
var currentSearch = ""; | |||
var currentFilter = "all"; | |||
var currentTab = "geral"; | |||
var searchTimeout = null; | |||
var filterDefault = root.querySelector("#gla-filter-default"); | |||
var filterColiseu = root.querySelector("#gla-filter-coliseu"); | |||
var searchInput = root.querySelector(".gla-conquistas-search"); | |||
function countHiddenInTab(tabName) { | |||
return hiddenCountByTab[tabName] || 0; | |||
} | } | ||
// | // Mostra a barra de filtros só quando faz sentido: na aba Coliseu mostra | ||
// o subset (One Man Army / Corrida). Nas outras, só mostra o filtro | |||
// normal/hidden se a aba tiver pelo menos uma conquista oculta — | |||
// se não tiver, esconde pra não poluir. | |||
function syncFilterBarForTab(tabName) { | |||
var hiddenCount = countHiddenInTab(tabName); | |||
if (filterColiseu) { | |||
filterColiseu.style.display = tabName === "coliseu" ? "" : "none"; | |||
} | |||
if (filterDefault) { | |||
if (tabName === "coliseu") { | |||
filterDefault.style.display = "none"; | |||
} else { | |||
filterDefault.style.display = hiddenCount > 0 ? "" : "none"; | |||
} | |||
} | } | ||
if (tabName !== "coliseu" && hiddenCount === 0) { | |||
if (currentFilter === "hidden" || currentFilter === "normal") { | |||
currentFilter = "all"; | |||
} | |||
if (filterDefault) { | |||
filterDefault.querySelectorAll(".gla-conquistas-filter").forEach(function (p) { | |||
p.classList.toggle("is-active", p.getAttribute("data-filter") === "all"); | |||
}); | |||
} | |||
} | } | ||
} | } | ||
// Normalização pra busca: lowercase + remove acentos (NFD divide | |||
// base+diacrítico, regex remove os diacríticos). Assim "voce" | |||
// acha "você", "missao" acha "missão", etc. | |||
function normalize(s) { | |||
return (s || "") | |||
.toLowerCase() | |||
} | .normalize("NFD") | ||
.replace(/[̀-ͯ]/g, ""); | |||
} | |||
// Cacheia o "haystack" (title + desc normalizados) no próprio card | |||
// pra não recalcular a cada keystroke. Recomputa só se o conteúdo | |||
// do card mudar (não acontece na vida do widget — é estático). | |||
function getHaystack(card) { | |||
var cached = card.__glaHaystack; | |||
if (cached) return cached; | |||
var titleEl = card.querySelector(".gla-item-title"); | |||
var descEl = card.querySelector(".gla-item-desc"); | |||
var titleText = titleEl ? titleEl.textContent : ""; | |||
var descText = descEl ? descEl.textContent : ""; | |||
// | cached = normalize(titleText + " " + descText); | ||
card.__glaHaystack = cached; | |||
// | return cached; | ||
function | |||
} | } | ||
function | function applyVisibility() { | ||
var cards = cardsByTab[currentTab] || []; | |||
if (!cards.length) return; | |||
// Token-based search: divide a query em palavras e exige que | |||
// TODAS apareçam no haystack (title + desc), em qualquer | |||
// ordem. "voce grand" → tokens ["voce", "grand"] → casa com | |||
// "Você entrou na Grand Line pela primeira vez". Acentos e | |||
// case são ignorados via normalize(). | |||
var tokens = normalize(currentSearch).split(/\s+/).filter(Boolean); | |||
cards.forEach(function (card) { | |||
var matchSearch = true; | |||
if (tokens.length > 0) { | |||
var hay = getHaystack(card); | |||
for (var i = 0; i < tokens.length; i++) { | |||
if (hay.indexOf(tokens[i]) === -1) { matchSearch = false; break; } | |||
} | |||
} | |||
var hidden = card.getAttribute("data-hidden") === "true"; | |||
var subtype = (card.getAttribute("data-subtype") || "").toLowerCase(); | |||
var matchFilter = true; | |||
if (currentFilter === "normal") matchFilter = !hidden; | |||
else if (currentFilter === "hidden") matchFilter = hidden; | |||
else if (currentFilter === "onemany") matchFilter = subtype === "onemany"; | |||
else if (currentFilter === "corrida") matchFilter = subtype === "corrida"; | |||
card.style.display = (matchSearch && matchFilter) ? "" : "none"; | |||
}); | |||
} | |||
// Busca com debounce | |||
if (searchInput) { | |||
searchInput.addEventListener("input", function () { | |||
clearTimeout(searchTimeout); | |||
} | searchTimeout = setTimeout(function () { | ||
currentSearch = searchInput.value.trim(); | |||
applyVisibility(); | |||
}, 240); | |||
}); | |||
} | |||
// Filtros (normal / oculta / subtype) | |||
root.querySelectorAll(".gla-conquistas-filter").forEach(function (pill) { | |||
pill.addEventListener("click", function () { | |||
pill.parentNode.querySelectorAll(".gla-conquistas-filter").forEach(function (p) { | |||
p.classList.remove("is-active"); | |||
}); | |||
pill.classList.add("is-active"); | |||
currentFilter = pill.getAttribute("data-filter"); | |||
applyVisibility(); | |||
}); | |||
}); | }); | ||
// Spoiler — abre/fecha só no botão "Spoiler". | |||
// O Lua emite <span role="button"> (MediaWiki não aceita <button> | |||
// em wikitext), por isso aceitamos click + Enter/Space pra | |||
// preservar semântica de botão acessível. | |||
var openSpoilerCard = null; | |||
var openSpoilerToggle = null; | |||
function closeOpenSpoiler() { | |||
if (openSpoilerCard) { | |||
openSpoilerCard.classList.remove("is-open"); | |||
openSpoilerCard = null; | |||
} | |||
if (openSpoilerToggle) { | |||
openSpoilerToggle.setAttribute("aria-expanded", "false"); | |||
openSpoilerToggle = null; | |||
} | |||
} | |||
function toggleSpoiler(toggle) { | |||
var | var card = toggle.closest(".gla-item.has-spoiler"); | ||
if (!card) return; | |||
if ( | var isOpen = (openSpoilerCard === card); | ||
closeOpenSpoiler(); | |||
if (!isOpen) { | |||
card.classList.add("is-open"); | |||
toggle.setAttribute("aria-expanded", "true"); | |||
openSpoilerCard = card; | |||
openSpoilerToggle = toggle; | |||
} | |||
} | } | ||
root.addEventListener("click", function (e) { | |||
var toggle = e.target.closest(".gla-item-spoiler-toggle"); | |||
if (!toggle) return; | |||
if (e.target.closest(".item-wrapper")) return; | |||
e.preventDefault(); | |||
toggleSpoiler(toggle); | |||
}); | }); | ||
root.addEventListener("keydown", function (e) { | |||
if (e.key !== "Enter" && e.key !== " ") return; | |||
var toggle = e.target.closest(".gla-item-spoiler-toggle"); | |||
if (!toggle) return; | |||
e.preventDefault(); | |||
toggleSpoiler(toggle); | |||
}); | |||
// ─── Reveal + persistência ──────────────────────────────────────── | |||
function saveRevealed(set) { | |||
try { | |||
var arr = Object.keys(set); | |||
window.localStorage.setItem(REVEAL_STORAGE_KEY, JSON.stringify(arr)); | |||
} catch (e) { /* localStorage indisponível — ok */ } | |||
} | |||
// Re-aplica .is-revealed nos cards que já tavam revelados em | |||
// sessões anteriores. Roda agora (depois do JS já ter movido os | |||
// cards do source pros painéis). | |||
root.querySelectorAll('.gla-item[data-hidden="true"][data-id]').forEach(function (card) { | |||
if (revealedSet[card.getAttribute("data-id")]) { | |||
card.classList.add("is-revealed"); | |||
// | |||
if ( | |||
} | } | ||
}); | |||
} | |||
// Click — revela e persiste. Só ativa se data-reveal-mode no root | |||
// estiver definido como blur/redacted/placeholder/veil. | |||
var | // | ||
// O CSS aplica `pointer-events: none` no .gla-item-main enquanto | |||
// a censura está ativa — então click em qualquer ponto interno | |||
var | // (icon/title/desc/items/spoiler/chip+N) cai direto no .gla-item. | ||
if ( | // Aqui não precisamos mais filtrar interativos: enquanto censurado, | ||
if ( | // tudo é "click pra revelar"; depois que revela, os filtros internos | ||
// voltam ao normal porque o seletor :not(.is-revealed) deixa de bater. | |||
root.addEventListener("click", function (e) { | |||
var mode = root.getAttribute("data-reveal-mode") || "none"; | |||
if (mode === "none") return; | |||
var card = e.target.closest('.gla-item[data-hidden="true"]'); | |||
if (!card) return; | |||
if (card.classList.contains("is-revealed")) return; | |||
card.classList.add("is-revealed"); | |||
var id = card.getAttribute("data-id"); | |||
if (id) { | |||
var | revealedSet[id] = true; | ||
if ( | saveRevealed(revealedSet); | ||
} | } | ||
}); | |||
function finishAbrirAba(nome) { | |||
syncFilterBarForTab(nome); | |||
root.querySelectorAll(".gla-conquistas-filter").forEach(function (p) { | |||
if (p.offsetParent !== null && p.style.display !== "none") { | |||
p.classList.toggle("is-active", p.getAttribute("data-filter") === "all"); | |||
} | |||
}); | |||
applyVisibility(); | |||
} | |||
function abrirAba(nome) { | |||
currentTab = nome; | |||
currentFilter = "all"; | |||
closeOpenSpoiler(); | |||
tabs.forEach(function (t) { t.classList.remove("is-active"); }); | |||
panels.forEach(function (p) { p.classList.remove("is-active"); }); | |||
var tab = tabByName[nome]; | |||
var panel = panelByTab[nome]; | |||
if (tab) tab.classList.add("is-active"); | |||
if (panel) panel.classList.add("is-active"); | |||
ensureTabLoaded(nome).then(function () { | |||
finishAbrirAba(nome); | |||
}); | }); | ||
} | |||
tabs.forEach(function (tab) { | |||
} | tab.addEventListener("click", function () { | ||
abrirAba(tab.getAttribute("data-tab")); | |||
}); | |||
}); | |||
function closeAll() { | abrirAba("geral"); | ||
if (activePopover) { | }); | ||
activePopover.hidden = true; | </script> | ||
activePopover.style.pointerEvents = "none"; | |||
activePopover = null; | <!-- ─── Overflow das recompensas: "+M" chip + popover ───────────────────── | ||
} | Para cada .reward-items com mais de MAX itens, esconde do (MAX+1)º em | ||
if (activeChip) { | diante, joga os escondidos num popover, e planta um chip "+M" no fim | ||
da linha. Clique no chip abre o popover ancorado abaixo do chip. | |||
Click fora fecha. Esc fecha. | |||
É responsabilidade do widget — o Lua não precisa mudar, já que | |||
.reward-items vem do Widget:Item igual à Predefinição:Reward. | |||
─────────────────────────────────────────────────────────────────────── --> | |||
<script> | |||
(function () { | |||
var MAX_VISIBLE = 4; | |||
// Container portal: vive direto em document.body, fora de | |||
// qualquer .gla-item / .gla-item-reward. Necessário pra que | |||
// cards com overflow:hidden (ex.: variante "ribbon") não | |||
// recortem o popover nem os tooltips dos itens dentro dele. | |||
var portal = null; | |||
var activeChip = null; | |||
var activePopover = null; | |||
function ensurePortal() { | |||
if (portal && portal.isConnected) return portal; | |||
portal = document.querySelector(".gla-conquistas-portal"); | |||
if (!portal) { | |||
portal = document.createElement("div"); | |||
portal.className = "gla-conquistas-portal"; | |||
portal.style.position = "absolute"; | |||
portal.style.top = "0"; | |||
portal.style.left = "0"; | |||
portal.style.width = "0"; | |||
portal.style.height = "0"; | |||
portal.style.pointerEvents = "none"; | |||
portal.style.zIndex = "9998"; | |||
document.body.appendChild(portal); | |||
} | |||
return portal; | |||
} | |||
function positionPopover(popover, chip) { | |||
var rect = chip.getBoundingClientRect(); | |||
var pw = popover.offsetWidth || 240; | |||
var vpW = window.innerWidth || document.documentElement.clientWidth; | |||
// Alinha pela direita do chip; clampa pra não vazar viewport. | |||
var left = rect.right - pw; | |||
if (left < 8) left = 8; | |||
if (left + pw > vpW - 8) left = vpW - 8 - pw; | |||
popover.style.left = left + "px"; | |||
popover.style.top = (rect.bottom + 6) + "px"; | |||
popover.style.pointerEvents = "auto"; | |||
} | |||
function processOne(reward) { | |||
if (reward.dataset.glaOverflow === "done") return; | |||
var line = reward.querySelector(".reward-items"); | |||
if (!line) return; | |||
var items = []; | |||
for (var i = 0; i < line.children.length; i++) { | |||
var c = line.children[i]; | |||
if (c.classList && c.classList.contains("item-wrapper")) items.push(c); | |||
} | |||
if (items.length <= MAX_VISIBLE) { | |||
reward.dataset.glaOverflow = "done"; | |||
return; | |||
} | |||
var hidden = items.slice(MAX_VISIBLE); | |||
var popover = document.createElement("div"); | |||
popover.className = "reward-overflow-popover"; | |||
popover.hidden = true; | |||
hidden.forEach(function (el) { popover.appendChild(el); }); | |||
var chip = document.createElement("button"); | |||
chip.type = "button"; | |||
chip.className = "reward-more-chip"; | |||
chip.setAttribute("aria-expanded", "false"); | |||
chip.setAttribute("aria-label", "Ver mais " + hidden.length + " recompensa(s)"); | |||
chip.title = "Ver mais " + hidden.length; | |||
// Reticências midline (U+22EF) — sinaliza "tem mais" sem | |||
// poluir visualmente com número. O aria-label e o title | |||
// informam a quantidade exata pra acessibilidade/hover. | |||
chip.textContent = "⋯"; | |||
line.appendChild(chip); | |||
// Anexa o popover ao portal global (não ao card) — escapa | |||
// overflow:hidden de qualquer ancestral. | |||
ensurePortal().appendChild(popover); | |||
chip.addEventListener("click", function (e) { | |||
e.stopPropagation(); | |||
var open = chip.getAttribute("aria-expanded") === "true"; | |||
closeAll(); | |||
if (!open) { | |||
popover.hidden = false; | |||
// Render primeiro (offsetWidth precisa do layout), | |||
// depois posiciona. | |||
positionPopover(popover, chip); | |||
chip.setAttribute("aria-expanded", "true"); | |||
chip.__glaPopover = popover; | |||
activeChip = chip; | |||
activePopover = popover; | |||
} | |||
}); | |||
reward.dataset.glaOverflow = "done"; | |||
} | |||
function closeAll() { | |||
if (activePopover) { | |||
activePopover.hidden = true; | |||
activePopover.style.pointerEvents = "none"; | |||
activePopover = null; | |||
} | |||
if (activeChip) { | |||
activeChip.setAttribute("aria-expanded", "false"); | activeChip.setAttribute("aria-expanded", "false"); | ||
activeChip = null; | activeChip = null; | ||
return; | return; | ||
} | } | ||
// fallback defensivo (estado legado inesperado) | // fallback defensivo (estado legado inesperado) | ||
document.querySelectorAll(".reward-overflow-popover").forEach(function (p) { | document.querySelectorAll(".reward-overflow-popover").forEach(function (p) { | ||
p.hidden = true; | p.hidden = true; | ||
p.style.pointerEvents = "none"; | p.style.pointerEvents = "none"; | ||
}); | }); | ||
document.querySelectorAll(".reward-more-chip[aria-expanded=\"true\"]").forEach(function (c) { | document.querySelectorAll(".reward-more-chip[aria-expanded=\"true\"]").forEach(function (c) { | ||
c.setAttribute("aria-expanded", "false"); | c.setAttribute("aria-expanded", "false"); | ||
}); | }); | ||
} | } | ||
// Reposiciona qualquer popover aberto em scroll/resize. | |||
function repositionOpen() { | |||
if (activeChip && activePopover && !activePopover.hidden) { | |||
positionPopover(activePopover, activeChip); | |||
} | |||
} | |||
window.addEventListener("resize", repositionOpen); | |||
window.addEventListener("scroll", repositionOpen, true); | |||
// Click fora do chip e do popover fecha tudo. | |||
document.addEventListener("click", function (e) { | |||
if (e.target.closest(".reward-more-chip")) return; | |||
if (e.target.closest(".reward-overflow-popover")) return; | |||
closeAll(); | |||
}); | |||
document.addEventListener("keydown", function (e) { | |||
if (e.key === "Escape") closeAll(); | |||
}); | |||
function processRewardsIn(rootEl) { | |||
function | if (!rootEl) return; | ||
if ( | rootEl.querySelectorAll(".gla-item-reward").forEach(processOne); | ||
} | } | ||
window.glaConquistasProcessRewards = processRewardsIn; | |||
function processAll() { | function processAll() { | ||
document | processRewardsIn(document); | ||
} | } | ||