CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
|
|
import "./HellGirlFlames.scss";
|
|
|
|
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
|
|
const BURN_MS = 1500;
|
|
|
|
/** `target` = obsah dialogu, ve kterém se hoří. */
|
|
type Burn = { src: string, target: HTMLElement };
|
|
|
|
/**
|
|
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
|
|
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
|
|
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
|
|
*/
|
|
const HellGirlFlames: React.FC = () => {
|
|
const [burning, setBurning] = useState<Burn>();
|
|
const flameBlobRef = useRef<Blob | undefined>(undefined);
|
|
|
|
const burn = (target: HTMLElement) => {
|
|
const blob = flameBlobRef.current;
|
|
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
|
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
|
|
setBurning({ src: URL.createObjectURL(blob), target });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!burning) return;
|
|
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
URL.revokeObjectURL(burning.src);
|
|
};
|
|
}, [burning]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const root = document.documentElement;
|
|
loadHellGirlImages().then(images => {
|
|
if (!images || cancelled) return;
|
|
fetch(images.flame)
|
|
.then(r => r.ok ? r.blob() : undefined)
|
|
.then(blob => { flameBlobRef.current = blob; })
|
|
.catch(() => { /* bez plamenů */ });
|
|
createSymbolMask(images.symbol).then(mask => {
|
|
if (!mask || cancelled) return;
|
|
root.style.setProperty('--hg-symbol', `url(${mask})`);
|
|
root.setAttribute('data-hg-symbol', '');
|
|
});
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
root.style.removeProperty('--hg-symbol');
|
|
root.removeAttribute('data-hg-symbol');
|
|
};
|
|
}, []);
|
|
|
|
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
|
|
useEffect(() => {
|
|
const observer = new MutationObserver(mutations => {
|
|
const modalContent = mutations
|
|
.flatMap(m => Array.from(m.addedNodes))
|
|
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
|
|
.find(element => !!element);
|
|
if (modalContent) burn(modalContent);
|
|
});
|
|
observer.observe(document.body, { childList: true });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
if (!burning) return null;
|
|
return createPortal(
|
|
<div className="hg-burn" aria-hidden="true">
|
|
<img className="hg-burn-flame" src={burning.src} alt="" />
|
|
</div>,
|
|
burning.target,
|
|
);
|
|
};
|
|
|
|
export default HellGirlFlames;
|