portal_publico/static/js/seasonal-theme.js

305 lines
15 KiB
JavaScript

/*
* Tema sazonal "Halloween" do Portal — troca a marca P.I.D. (favicon, ícone
* do login, ícone/assinatura da sidebar) pela variante "vampiro" durante uma
* janela de datas (outubro) e troca a piscada de olhos ao clicar na logo por
* uma animação de "susto" (capa se abre, olhos ficam vermelhos e raivosos,
* presas de fora, tremida + brilho vermelho) só nesse período — fora da
* janela, tudo volta ao P.I.D. normal (`pid-icon-eye`/`is-blinking`). Ver
* `docs/temas-sazonais/temas-sazonais.md`. Consumido por `sidebar-brand.js`,
* `login-logo-blink.js` e `auth.js` (troca de intro); incluído em toda
* página (mesmo padrão de `confirm-modal.js`) por isso mora aqui, não num
* arquivo específico de página.
*
* PID_HALLOWEEN_PREVIEW_FORCE forçava o tema ligado o ano todo pra revisão
* visual; validado e desligado pelo usuário — a partir daqui só a checagem
* de mês abaixo decide (outubro, todo ano).
*/
const PID_HALLOWEEN_PREVIEW_FORCE = false;
const PID_HALLOWEEN_MES = 9; // outubro, 0-indexed (Date#getMonth())
const PID_HALLOWEEN_SCARE_MS = 900;
/* Preferência do usuário (menu da conta → "Temas sazonais") pra desligar
qualquer tema sazonal (hoje só o Halloween, mas vale pra futuros também)
e manter sempre o P.I.D. padrão — motivos: gosto pessoal ou, no caso do
Halloween, aracnofobia. Mesmo padrão de armazenamento do tema claro/escuro
(`theme.js`, só `localStorage`, nunca migrado pro banco — é preferência
de navegador, não dado de negócio). Tem prioridade sobre tudo, inclusive
`PID_HALLOWEEN_PREVIEW_FORCE`: se o usuário desligou, fica desligado. */
const PID_SEASONAL_OPT_OUT_KEY = "pid_seasonal_theme_opt_out";
function pidSeasonalThemesOptedOut() {
try {
return localStorage.getItem(PID_SEASONAL_OPT_OUT_KEY) === "1";
} catch (e) {
return false;
}
}
function pidSetSeasonalThemesOptedOut(optedOut) {
try {
if (optedOut) {
localStorage.setItem(PID_SEASONAL_OPT_OUT_KEY, "1");
} else {
localStorage.removeItem(PID_SEASONAL_OPT_OUT_KEY);
}
} catch (e) {
// localStorage indisponível (modo privado, storage bloqueado etc.) — a
// preferência simplesmente não persiste, mas a página continua funcionando.
}
}
function pidIsHalloweenActive(data) {
if (pidSeasonalThemesOptedOut()) return false;
if (PID_HALLOWEEN_PREVIEW_FORCE) return true;
const hoje = data || new Date();
return hoje.getMonth() === PID_HALLOWEEN_MES;
}
/* Usa `pid-halloween-icone-escuro.svg` (o mesmo ícone/cores do vampiro
usado na sidebar e no login), não `pid-halloween-favicon.svg` — esse
último é uma variante simplificada (sem capa/gola, cores diferentes,
pensada só pra tamanhos bem pequenos) e destoava do resto da marca sazonal
na aba do navegador. Mesmo padrão do P.I.D. normal, cujo favicon também
usa `pid-icone-escuro.svg` em vez do `pid-favicon.svg` dedicado — ver
"Logos em `static/img/`" no CLAUDE.md. */
function pidApplySeasonalFavicon() {
const link = document.querySelector('link[rel="icon"]');
if (link && link.href.indexOf("pid-icone-escuro.svg") !== -1) {
link.href = link.href.replace("pid-icone-escuro.svg", "pid-halloween-icone-escuro.svg");
}
}
const PID_VAMP_FACE_FRIENDLY = `
<circle cx="30" cy="58" r="5" fill="#F2EDE3"></circle>
<circle cx="46" cy="58" r="5" fill="#F2EDE3"></circle>
<circle cx="32" cy="56.2" r="1.6" fill="#241C33"></circle>
<circle cx="48" cy="56.2" r="1.6" fill="#241C33"></circle>
<path d="M26 63.2 q4 2.6 8 -0.5" stroke="#7A2733" stroke-width="1.3" fill="none" stroke-linecap="round"></path>
<path d="M42 63.2 q4 2.6 8 -0.5" stroke="#7A2733" stroke-width="1.3" fill="none" stroke-linecap="round"></path>
<path d="M31 67 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
<path d="M33.6 68.6 L36.4 68.9 L35 72 Z" fill="#F2EDE3"></path>
<path d="M40.2 68.9 L43 68.6 L41.6 72 Z" fill="#F2EDE3"></path>`;
const PID_VAMP_FACE_SCARE = `
<circle cx="30" cy="57.4" r="5.6" fill="#F2EDE3"></circle>
<circle cx="46" cy="57.4" r="5.6" fill="#F2EDE3"></circle>
<circle cx="30" cy="57.4" r="2.1" fill="#9E3241"></circle>
<circle cx="46" cy="57.4" r="2.1" fill="#9E3241"></circle>
<path d="M24.6 51.4 L34.6 54.2" stroke="#7A2733" stroke-width="1.9" fill="none" stroke-linecap="round"></path>
<path d="M51.4 51.4 L41.4 54.2" stroke="#7A2733" stroke-width="1.9" fill="none" stroke-linecap="round"></path>
<path d="M29 66.4 q9 7.4 18 0 q-9 3 -18 0 Z" fill="#0C0814"></path>
<path d="M29 66.4 q9 7.4 18 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
<path d="M31.4 67.4 L34.6 67.9 L32.9 73 Z" fill="#F2EDE3"></path>
<path d="M41.4 67.9 L44.6 67.4 L43.1 73 Z" fill="#F2EDE3"></path>`;
function pidVampireIconRigMarkup() {
return `
<g class="pid-vamp-cape-group">
<path d="M4 9 L20 19 Q38 24 56 19 L72 9 C74 32 74 56 71 78 L5 78 C2 56 2 32 4 9 Z" fill="#2E2740"></path>
<path class="pid-vamp-collar" d="M9 16 L21 22 Q38 26 55 22 L67 16 C68 26 68 31 66 36 L10 36 C8 31 8 26 9 16 Z" fill="#7A2733"></path>
</g>
<path d="M16 22 h34 l14 14 v40 a2 2 0 0 1 -2 2 H14 a2 2 0 0 1 -2 -2 V24 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
<rect x="28" y="22" width="20" height="16" fill="#C6A24A"></rect>
<rect x="40" y="25" width="5" height="10" fill="#7B5BA8"></rect>
<rect x="18" y="46" width="40" height="28" rx="2" fill="#241C33"></rect>
<g class="pid-vamp-face pid-vamp-face--friendly">${PID_VAMP_FACE_FRIENDLY}</g>
<g class="pid-vamp-face pid-vamp-face--scare">${PID_VAMP_FACE_SCARE}</g>`;
}
/* Substitui o conteúdo de um <svg> de ícone (viewBox 0 0 64 64, o P.I.D. de
sempre) pelo vampiro (viewBox 0 0 76 80) — usado tanto no ícone da sidebar
colapsada quanto no botão da logo do login. `dataset.vampApplied` evita
reaplicar se a função rodar de novo (não deveria, mas por segurança). */
function pidApplyVampireIcon(svg) {
if (!svg || svg.dataset.vampApplied) return;
svg.setAttribute("viewBox", "0 0 76 80");
svg.innerHTML = `<g class="pid-vamp-icon-rig">${pidVampireIconRigMarkup()}</g>`;
svg.dataset.vampApplied = "1";
}
/* Idem, pra assinatura horizontal (ícone + "P.I.D." + tagline) da sidebar
expandida — viewBox 0 0 320 96 (a versão vampiro é um pouco mais larga que
os 300x80 do P.I.D. normal, por causa da capa). */
function pidApplyVampireFull(svg) {
if (!svg || svg.dataset.vampApplied) return;
svg.setAttribute("viewBox", "0 0 320 96");
svg.innerHTML = `
<g transform="translate(4,8)"><g class="pid-vamp-icon-rig">${pidVampireIconRigMarkup()}</g></g>
<text x="96" y="50" font-family="'Space Grotesk',Helvetica,Arial,sans-serif" font-size="34" font-weight="700" letter-spacing="1.4" fill="#F2EDE3">P.I.D.</text>
<text x="98" y="69" font-family="'DM Mono',ui-monospace,monospace" font-size="9.5" letter-spacing="2.6" fill="#C6A24A">PORTAL INTERNO DA DE PAULA</text>`;
svg.dataset.vampApplied = "1";
}
/* Dispara a animação de "susto" (`.is-scaring`/`pidVampShake` etc. em
base.css) em todo `.pid-vamp-icon-rig` dentro de `root` — substitui a
piscada de olhos (`.pid-icon-eye`/`is-blinking`) enquanto o tema Halloween
está ativo. Remove e reaplica a classe (em vez de só adicionar) pra
permitir cliques repetidos re-tocarem a animação do zero. */
function pidTriggerVampireScare(root) {
root.querySelectorAll(".pid-vamp-icon-rig").forEach((rig) => {
rig.classList.remove("is-scaring");
void rig.getBoundingClientRect();
rig.classList.add("is-scaring");
});
}
/* Teia de aranha decorativa de canto (`static/css/halloween-cobweb.css`) —
markup só existe em memória (nunca hardcoded no template) pra não sobrar
nada no HTML fora da janela do Halloween. `.pw-spider`/`.pw-legs` (mesmas
classes usadas pela animação em CSS) andam/mexem sozinhas via
`@keyframes` contínuo, sem precisar de mais JS depois de injetadas. */
const PID_COBWEB_SVG = `<svg viewBox="0 0 200 200" role="img" aria-hidden="true">
<g class="pw-strands">
<line x1="0" y1="0" x2="200.0" y2="10.5"></line>
<line x1="0" y1="0" x2="200.0" y2="53.6"></line>
<line x1="0" y1="0" x2="200.0" y2="106.3"></line>
<line x1="0" y1="0" x2="200.0" y2="180.1"></line>
<line x1="0" y1="0" x2="134.9" y2="200.0"></line>
<line x1="0" y1="0" x2="76.8" y2="200.0"></line>
<line x1="0" y1="0" x2="35.3" y2="200.0"></line>
<line x1="0" y1="0" x2="7.0" y2="200.0"></line>
<line class="pw-strand-stray" x1="0" y1="0" x2="296.3" y2="46.9"></line>
<path class="pw-ring" d="M 30.0,1.6 Q 32.4,5.2 30.0,8.0 Q 32.4,13.0 30.0,16.0 Q 32.4,23.2 30.0,27.0 Q 27.1,30.8 20.2,30.0 Q 17.1,32.4 11.5,30.0 Q 9.1,32.4 5.3,30.0 Q 3.4,32.4 1.0,30.0"></path>
<path class="pw-ring" d="M 58.0,3.0 Q 62.6,10.0 58.0,15.5 Q 62.6,25.0 58.0,30.8 Q 62.6,44.9 58.0,52.2 Q 52.4,59.5 39.1,58.0 Q 33.1,62.6 22.3,58.0 Q 17.5,62.6 10.2,58.0 Q 6.6,62.6 2.0,58.0"></path>
<path class="pw-ring" d="M 90.0,4.7 Q 97.2,15.6 90.0,24.1 Q 97.2,38.9 90.0,47.9 Q 97.2,69.6 90.0,81.0 Q 81.4,92.4 60.7,90.0 Q 51.4,97.2 34.5,90.0 Q 27.2,97.2 15.9,90.0 Q 10.3,97.2 3.1,90.0"></path>
<path class="pw-ring" d="M 126.0,6.6 Q 136.1,21.8 126.0,33.8 Q 136.1,54.4 126.0,67.0 Q 136.1,97.4 126.0,113.5 Q 113.9,129.3 85.0,126.0 Q 72.0,136.1 48.4,126.0 Q 38.1,136.1 22.2,126.0 Q 14.4,136.1 4.4,126.0"></path>
<path class="pw-ring" d="M 166.0,8.7 Q 179.3,28.7 166.0,44.5 Q 179.3,71.7 166.0,88.3 Q 179.3,128.4 166.0,149.5 Q 150.1,170.4 112.0,166.0 Q 94.9,179.3 63.7,166.0 Q 50.2,179.3 29.3,166.0 Q 18.9,179.3 5.8,166.0"></path>
</g>
<g class="pw-spider">
<g class="pw-legs">
<path d="M-3,-2 L-14,-8 L-22,-6"></path>
<path d="M-2,-1 L-13,2 L-21,5"></path>
<path d="M-2,1 L-13,7 L-20,13"></path>
<path d="M-3,2 L-12,11 L-18,18"></path>
<path d="M3,-2 L14,-8 L22,-6"></path>
<path d="M2,-1 L13,2 L21,5"></path>
<path d="M2,1 L13,7 L20,13"></path>
<path d="M3,2 L12,11 L18,18"></path>
</g>
<ellipse class="pw-abdomen" cx="0" cy="6" rx="7" ry="9"></ellipse>
<circle class="pw-head" cx="0" cy="-6" r="4.5"></circle>
<circle class="pw-eye" cx="-1.6" cy="-7" r="0.9"></circle>
<circle class="pw-eye" cx="1.6" cy="-7" r="0.9"></circle>
</g>
<g class="pw-skull">
<circle cx="0" cy="0" r="6.5"></circle>
<ellipse class="pw-skull-socket" cx="-2.4" cy="-0.6" rx="1.5" ry="1.9"></ellipse>
<ellipse class="pw-skull-socket" cx="2.4" cy="-0.6" rx="1.5" ry="1.9"></ellipse>
<path d="M-1.2 3.4 L-0.4 2.2 L0.4 3.4 L1.2 2.2"></path>
</g>
</svg>`;
/* Quais aranhas já "morreram" (clicadas numa visita anterior) — persistido
pra sobreviver a um F5/nova navegação, pedido explícito do usuário
("guardar que a aranha foi morta... até executar o comando tab+b que
reseta a aranha"). Cada aranha tem um id próprio (`${containerId}__tl`/
`__br` — os dois cantos de cada uma das 2 páginas, 4 ids possíveis no
total) guardado como array JSON num único `localStorage`, mesmo padrão
de armazenamento das outras preferências sazonais (`pid_theme`,
`pid_seasonal_theme_opt_out`) — nunca migrado pro banco. */
const PID_DEAD_SPIDERS_KEY = "pid_seasonal_dead_spiders";
function pidGetDeadSpiders() {
try {
const raw = JSON.parse(localStorage.getItem(PID_DEAD_SPIDERS_KEY) || "[]");
return Array.isArray(raw) ? raw : [];
} catch (e) {
return [];
}
}
function pidMarkSpiderDead(spiderId) {
try {
const dead = pidGetDeadSpiders();
if (!dead.includes(spiderId)) {
dead.push(spiderId);
localStorage.setItem(PID_DEAD_SPIDERS_KEY, JSON.stringify(dead));
}
} catch (e) {
// localStorage indisponível — a aranha "morre" só nesta visita, sem persistir
}
}
function pidResetDeadSpiders() {
try {
localStorage.removeItem(PID_DEAD_SPIDERS_KEY);
} catch (e) {
// ignorado
}
}
/* Preenche o container (já presente e `hidden` no template, `#pw-cobwebs-login`
em index.html ou `#pw-cobwebs-portal` em portal.html) com os 2 cantos
(superior-esquerdo/inferior-direito, pedido explícito do usuário) e revela.
Se a aranha daquele canto já morreu numa visita anterior
(`pidGetDeadSpiders()`), nasce direto com `.is-dead` (sem tocar a
animação de novo — só um "estado final" instantâneo). Senão, liga o
clique (`.pw-spider`, `pointer-events:auto` só nela — o resto da teia
continua `pointer-events:none`): "morre" (cai girando e some, pernas
encolhem — `.is-dying`/`pwSpiderDie` em halloween-cobweb.css) e uma
caveirinha sobe da teia (`.pw-skull`, `.is-rising`/`pwSkullRise`),
deixando a teia vazia — e grava o óbito (`pidMarkSpiderDead()`), que só
é desfeito pelo atalho de teclado Tab+B (ver `pidBindSeasonalResetShortcut()`
abaixo). É um no-op seguro em qualquer outra página (o id não existe ali). */
function pidApplyHalloweenCobwebs(containerId) {
const el = document.getElementById(containerId);
if (!el || el.dataset.cobwebApplied) return;
el.innerHTML =
`<div class="pw-corner pw-corner--tl">${PID_COBWEB_SVG}</div>` +
`<div class="pw-corner pw-corner--br">${PID_COBWEB_SVG}</div>`;
el.hidden = false;
el.dataset.cobwebApplied = "1";
const deadSpiders = pidGetDeadSpiders();
el.querySelectorAll(".pw-corner").forEach((cornerEl) => {
const corner = cornerEl.classList.contains("pw-corner--tl") ? "tl" : "br";
const spiderId = `${containerId}__${corner}`;
const spider = cornerEl.querySelector(".pw-spider");
if (!spider) return;
if (deadSpiders.includes(spiderId)) {
spider.classList.add("is-dead");
return;
}
spider.addEventListener(
"click",
() => {
const skull = spider.parentElement.querySelector(".pw-skull");
spider.classList.add("is-dying");
if (skull) skull.classList.add("is-rising");
pidMarkSpiderDead(spiderId);
},
{ once: true }
);
});
}
/* Atalho de teclado Tab+B (segurar Tab e apertar B) — reseta todas as
aranhas "mortas" salvas (`pidResetDeadSpiders()`) e recarrega a página,
mesmo raciocínio do toggle de opt-out (`account.js`): o estado já foi
aplicado no carregamento atual, não dá pra desfazer in-place. Não chama
`preventDefault()` no Tab (continuaria navegando o foco normalmente,
acessibilidade intacta) — só rastreia se ele está pressionado no momento
em que o B chega. Incluído em toda página (mesmo padrão de
`confirm-modal.js`), mas só tem efeito onde há aranha salva como morta. */
document.addEventListener("DOMContentLoaded", () => {
let tabHeld = false;
document.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
tabHeld = true;
return;
}
if (tabHeld && (event.key === "b" || event.key === "B") && pidGetDeadSpiders().length) {
pidResetDeadSpiders();
window.location.reload();
}
});
document.addEventListener("keyup", (event) => {
if (event.key === "Tab") tabHeld = false;
});
});