305 lines
15 KiB
JavaScript
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;
|
|
});
|
|
});
|